Compare commits

...

39 Commits

Author SHA1 Message Date
jmiller 13af787c13 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-30 19:19:09 +00:00
gitea-actions[bot] 481e395624 chore: promote changelog [Unreleased] → [02.52.24] 2026-06-30 19:18:28 +00:00
gitea-actions[bot] 202a26847b chore(release): build 02.52.24 [skip ci] 2026-06-30 19:18:21 +00:00
jmiller 7b38e238f5 Merge pull request 'fix: use warning status when backup succeeds but upload fails' (#199) from fix/upload-fail-warning-status into main 2026-06-30 19:18:06 +00:00
gitea-actions[bot] 9820d75212 chore(version): pre-release bump to 02.52.24-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 24s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 2m56s
2026-06-30 19:17:57 +00:00
jmiller 9c0c6eae15 docs: add warning status changes to changelog
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Project CI / Lint & Validate (pull_request) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 36s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 14:17:34 -05:00
gitea-actions[bot] 1daa6869cc chore(version): pre-release bump to 02.52.23-dev [skip ci] 2026-06-30 19:14:33 +00:00
jmiller aefa46e0c4 fix: use warning status when backup succeeds but remote upload fails
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 19s
Previously a successful backup with a failed remote upload was marked
as "complete", hiding the upload failure. Now these records get a
"warning" status with a yellow badge so operators can see at a glance
which backups didn't reach their remote destination.

Warning-status records are treated as usable backups throughout:
- Downloadable, browsable, and restorable (the archive is intact)
- Counted in dashboard stats, storage totals, and success streaks
- Included in purge operations and differential base lookups
- Shown with yellow "warning" badge in list, detail, and cpanel module
- Filterable via the status dropdown on Backup Records

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 14:14:06 -05:00
jmiller ed55ab068b chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-30 18:56:24 +00:00
gitea-actions[bot] 765e6feea6 chore: promote changelog [Unreleased] → [02.52.22] 2026-06-30 18:53:53 +00:00
gitea-actions[bot] 007036301d chore(release): build 02.52.22 [skip ci] 2026-06-30 18:53:45 +00:00
jmiller 1db4015003 Merge pull request 'fix: cancel stalled backups with ACL + auto-timeout failsafe' (#197) from fix/cancel-stalled-backup into main 2026-06-30 18:53:15 +00:00
gitea-actions[bot] 8dac5a7448 chore(version): pre-release bump to 02.52.22-dev [skip ci]
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 11m45s
2026-06-30 18:53:06 +00:00
jmiller 4560ffb84b docs: update changelog with cancel stalled and pre-update modal
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Project CI / Lint & Validate (pull_request) Successful in 11s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 29s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 34s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 13:52:42 -05:00
gitea-actions[bot] 5be922613f chore(version): pre-release bump to 02.52.21-dev [skip ci] 2026-06-30 18:50:40 +00:00
jmiller b26a21820b fix: auto-cancel stalled backups after 30 min timeout
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 29s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 43s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 47s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
PreflightCheck now auto-cancels "running" backup records that have
exceeded 30 minutes, treating them as stalled. Partial archive files
are cleaned up. The auto-cancelled records are surfaced as warnings
so the user knows what happened.

Records younger than 30 minutes are assumed to be legitimately running
and still block new backups for the same profile.

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 13:50:18 -05:00
gitea-actions[bot] a56f72b186 chore(version): pre-release bump to 02.52.20-dev [skip ci] 2026-06-30 18:49:16 +00:00
jmiller 1eb1c18bdf fix: add cancel stalled backup action with ACL permission
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Backups stuck in "running" status block all future backups for the same
profile via the preflight check. Previously the only fix was a manual
DB update.

Adds a toolbar button and AJAX endpoint to cancel stalled backups:
- New ACL permission: mokosuitebackup.backup.cancel
- BackupsController::cancelStalled() for toolbar (multi-select)
- AjaxController::cancelBackup() for AJAX/API use
- Sets status to "fail", cleans up partial archive files
- Updated preflight error message to mention the cancel action
- Language keys for en-GB and en-US

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 13:48:51 -05:00
jmiller eea1a40265 Merge pull request 'fix: remove orphaned deploy-manual workflow' (#195) from fix/remove-deploy-manual into main
fix: remove orphaned deploy-manual workflow [skip ci]
2026-06-30 18:33:51 +00:00
jmiller ebc692789c chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-30 18:12:34 +00:00
gitea-actions[bot] 983ec77dbd chore: promote changelog [Unreleased] → [02.52.18] 2026-06-30 18:12:06 +00:00
gitea-actions[bot] 25cf65f4cf chore(release): build 02.52.18 [skip ci] 2026-06-30 18:11:56 +00:00
jmiller 2d3a697f22 Merge pull request 'fix: remote upload prefix mismatch and restore security file' (#193) from fix/sftp-upload-and-restore-security into main 2026-06-30 18:11:32 +00:00
jmiller a4df3a651d fix: remove orphaned deploy-manual workflow [skip ci] 2026-06-30 18:07:18 +00:00
gitea-actions[bot] 3d9c48f40f chore(version): pre-release bump to 02.52.18-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Successful in 2m23s
2026-06-30 18:00:27 +00:00
jmiller 4093267984 fix: add verbose error_log throughout restore script
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Project CI / Lint & Validate (pull_request) Successful in 16s
Universal: PR Check / Secret Scan (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 30s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Every action handler now logs entry, key parameters, outcomes, and
failures to PHP error_log. Security file creation logs directory
permissions, PHP user, and the specific error when file_put_contents
fails. Database import logs SQL file size, statement counts, and
individual errors. Cleanup logs each file removal success/failure.

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 12:59:59 -05:00
gitea-actions[bot] 236609341f chore(version): pre-release bump to 02.52.17-dev [skip ci] 2026-06-30 17:53:59 +00:00
jmiller 20ce945e73 fix: recreate security file if missing while verification is pending
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 11s
Generic: Project CI / Lint & Validate (pull_request) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 34s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
The security file was only written inside the code-generation block
(first page load). If the file was deleted or failed to write, it
was never recreated because the session already held the code. Now
file writing is a separate check that runs whenever verification is
pending and the file is missing.

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 12:53:36 -05:00
gitea-actions[bot] f2f424a565 chore(version): pre-release bump to 02.52.16-dev [skip ci] 2026-06-30 17:50:30 +00:00
jmiller 3a6bb1c783 fix: remote upload prefix mismatch and restore security file visibility
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Remote uploaders (SFTP, FTP, S3, Google Drive) expect type-prefixed
property names (sftp_host, ftp_port, etc.) but createUploaderFromParams
passes unprefixed keys from the remotes table params JSON. Add prefix
mapping in createUploaderFromParams to bridge the naming gap.

Rename .mokorestore-security.php to mokorestore-security.php (no leading
dot) so the file is visible in file managers and not blocked by web
server dotfile rules. Also clean it up in actionCleanup.

Closes #13

Claude-Session: https://claude.ai/code/session_01MbEjBtsSjPuTWhqqrMS2wG
2026-06-30 12:50:02 -05:00
jmiller 785ffd85a3 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-28 20:10:19 +00:00
jmiller 71da4af64b chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-28 19:39:25 +00:00
jmiller 4d5711c304 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-28 19:39:22 +00:00
gitea-actions[bot] 1a4bb32c6c chore: promote changelog [Unreleased] → [01.45.00] 2026-06-28 19:31:25 +00:00
gitea-actions[bot] 13a526d6be chore(release): build 01.45.00 [skip ci] 2026-06-28 19:31:21 +00:00
jmiller babdb9e390 ci: add submodules: recursive to checkout (fixes MokoSuiteClient packaging) 2026-06-28 19:30:15 +00:00
jmiller 57c9ea600b ci: add submodules: recursive to checkout (fixes MokoSuiteClient packaging) 2026-06-28 19:25:18 +00:00
jmiller afffef78bd chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-28 19:12:12 +00:00
jmiller 720f008050 Merge pull request 'release: promote dev to main (v01.43.37)' (#159) from dev into main 2026-06-28 19:03:51 +00:00
46 changed files with 376 additions and 311 deletions
+3 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: mokocli.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.01.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +=======================================================================+ # +=======================================================================+
@@ -75,6 +75,7 @@ jobs:
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
submodules: recursive
- name: Setup mokocli tools - name: Setup mokocli tools
env: env:
@@ -173,6 +174,7 @@ jobs:
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive
- name: Configure git for bot pushes - name: Configure git for bot pushes
run: | run: |
-126
View File
@@ -1,126 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
# PATH: /templates/workflows/joomla/deploy-manual.yml.template
# VERSION: 04.07.00
# BRIEF: Manual SFTP deploy to dev server for Joomla repos
name: "Universal: Deploy to Dev (Manual)"
on:
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all remote files before uploading'
required: false
default: 'false'
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: read
jobs:
deploy:
name: SFTP Deploy to Dev
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Setup PHP
run: |
php -v && composer --version
- name: Setup MokoStandards tools
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.MOKOGITEA_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \
/tmp/mokostandards-api 2>/dev/null || true
if [ -d "/tmp/mokostandards-api" ] && [ -f "/tmp/mokostandards-api/composer.json" ]; then
cd /tmp/mokostandards-api && composer install --no-dev --no-interaction --quiet 2>/dev/null || true
fi
- name: Check FTP configuration
id: check
env:
HOST: ${{ vars.DEV_FTP_HOST }}
PATH_VAR: ${{ vars.DEV_FTP_PATH }}
PORT: ${{ vars.DEV_FTP_PORT }}
run: |
if [ -z "$HOST" ] || [ -z "$PATH_VAR" ]; then
echo "DEV_FTP_HOST or DEV_FTP_PATH not configured -- cannot deploy"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "host=$HOST" >> "$GITHUB_OUTPUT"
REMOTE="${PATH_VAR%/}"
echo "remote=$REMOTE" >> "$GITHUB_OUTPUT"
[ -z "$PORT" ] && PORT="22"
echo "port=$PORT" >> "$GITHUB_OUTPUT"
- name: Deploy via SFTP
if: steps.check.outputs.skip != 'true'
env:
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASS: ${{ secrets.DEV_FTP_PASSWORD }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
[ ! -d "$SOURCE_DIR" ] && { echo "No src/ or htdocs/ -- nothing to deploy"; exit 0; }
printf '{"host":"%s","port":%s,"username":"%s","remotePath":"%s"' \
"${{ steps.check.outputs.host }}" "${{ steps.check.outputs.port }}" "$SFTP_USER" "${{ steps.check.outputs.remote }}" \
> /tmp/sftp-config.json
if [ -n "$SFTP_KEY" ]; then
echo "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
printf ',"privateKeyPath":"/tmp/deploy_key"}' >> /tmp/sftp-config.json
else
printf ',"password":"%s"}' "$SFTP_PASS" >> /tmp/sftp-config.json
fi
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
[ "${{ inputs.clear_remote }}" = "true" ] && DEPLOY_ARGS+=(--clear-remote)
PLATFORM=$(php /tmp/mokostandards-api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards-api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards-api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards-api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Summary
if: always()
run: |
if [ "${{ steps.check.outputs.skip }}" = "true" ]; then
echo "### Deploy Skipped -- FTP not configured" >> $GITHUB_STEP_SUMMARY
else
echo "### Manual Dev Deploy Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Host | \`${{ steps.check.outputs.host }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Remote | \`${{ steps.check.outputs.remote }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Clear | ${{ inputs.clear_remote }} |" >> $GITHUB_STEP_SUMMARY
fi
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 01.44.01 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+28 -131
View File
@@ -1,7 +1,34 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [02.52.24] --- 2026-06-30
## [02.52.24] --- 2026-06-30
## [02.52.22] --- 2026-06-30
### Added
- Cancel Stalled toolbar button on Backup Records view to cancel backups stuck in "running" status
- New ACL permission `mokosuitebackup.backup.cancel` for cancel stalled action
- AJAX endpoint `ajax.cancelBackup` for programmatic/API cancel
- Auto-timeout failsafe: preflight auto-cancels "running" backups older than 30 minutes
- Pre-extension-update backup progress modal (Bootstrap 5 modal with stepped AJAX progress bar)
- New `warning` backup status for records where archive succeeded but remote upload failed
- Warning-status records are downloadable, browsable, restorable, and purgeable
- Warning status filter option in Backup Records dropdown
- Yellow "Warning" badge in backup list, detail view, and cpanel module
### Fixed
- Pre-update backup ran synchronously with no browser feedback — page hung until complete
- Stalled backups permanently blocked future backups for the same profile
- Preflight error message now directs users to Cancel Stalled action
- Backups with failed remote uploads were marked as "complete", hiding the upload failure
## [02.52.18] --- 2026-06-30
## [01.45.00] --- 2026-06-28
## [01.43.35] --- 2026-06-28 ## [01.43.35] --- 2026-06-28
### Added ### Added
@@ -26,133 +53,3 @@
- Options page title now shows "MokoSuiteBackup Options" instead of raw language key - Options page title now shows "MokoSuiteBackup Options" instead of raw language key
- Profile dropdown IDs in backup records and dashboard show "#ID — Title (type)" format - Profile dropdown IDs in backup records and dashboard show "#ID — Title (type)" format
- MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state - MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state
## [01.43.00] --- 2026-06-24
## [01.42.00] --- 2026-06-23
## [01.42.00] --- 2026-06-23
## [01.41.00] — 2026-06-23
### Added — Multi-Remote Storage
- New `#__mokosuitebackup_remotes` table for multiple destinations per profile
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit
- Engine uploads to ALL enabled destinations (BackupEngine + SteppedBackupEngine)
- Migration auto-converts existing SFTP/S3/GDrive/FTP profile columns to new table
- Backward compatibility: falls back to legacy single-remote columns if table empty
- Secrets masked in API responses, merged from DB on save
### Added — Content Snapshots
- Lightweight JSON snapshots of articles, categories, and modules
- Includes tags, custom fields, workflow associations, field values
- Restore modes: Replace (clean slate), Merge (upsert), Selective (per-article)
- Snapshot retention: max count + max age with automatic cleanup
- Scheduled snapshot task via com_scheduler
- CLI: `mokosuitebackup:snapshot create|restore|list|delete`
- REST API: create, list, restore, delete, download snapshots
- Tabbed browse modal: Articles / Categories / Modules with item counts
### Added — SFTP Remote Storage
- SFTP support with SSH key file authentication (key stored base64 in database)
- Auth type dropdown: Password / Key File / Key File + Passphrase
- SshKeyField: file upload via FileReader, key never exposed in HTML
- SFTP remote directory browser for path selection
- `__KEEP_EXISTING__` sentinel preserves key on profile re-save
### Added — MokoRestore Wizard (9 steps)
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
- Preset buttons: "All Replace", "All Skip", "Everything except users"
- Post-restore actions: reset passwords, hits, versions, sessions, cache
- Auto-detect sanitized passwords and prompt for reset (random temp password)
- Standalone mode: restore.php scans directory for ZIP files
- Wrapped mode: restore.php bundled inside backup ZIP
- Security gate with filesystem verification + path traversal protection
### Added — Data Sanitization
- Sanitize user passwords: replace hashes with invalid sentinel
- Sanitize user emails: replace with dummy values
- Clear session data: exclude `#__session` table
- Preserve super admin credentials (optional)
- GDPR-friendly backup sharing for demos and staging sites
### Added — Backup Engine
- Pre-flight validation: directory, disk space, extensions, credentials, running backups
- Auto-verify archive integrity after creation (ZIP, tar.gz, 7z)
- 7z archive format via system 7za/7z CLI binary with native encryption
- Streaming database dump to temp file (prevents OOM on large sites)
- S3 streaming upload via CURLOPT_PUT (prevents OOM)
- Graceful remote degradation: local backup preserved if upload fails
- DatabaseDumper::dumpToFile() for memory-efficient operation
### Added — Admin UI
- Dashboard: snapshot widget, 30-day backup trend chart, per-profile storage breakdown
- CPanel admin dashboard module (mod_mokosuitebackup_cpanel) with quick actions
- Backup type filter dropdown in backups list
- Backup comparison: select two backups for side-by-side diff
- Archive browser: view files inside backup without extracting
- Manual purge: delete backups older than a date with count preview
- Backup count badges on profile list
- "Do not navigate away" warning in backup/restore progress modals
- Clickable placeholder pills for backup directory and archive name fields
- Comprehensive help modal with absolute/relative/placeholder path documentation
- Placeholder resolution display with EXAMPLE prefix
- All placeholders UPPERCASE: [HOST], [SITE_NAME], [DATE], [DATETIME], etc.
### Added — CLI & API
- `mokosuitebackup:restore` with --files-only, --db-only, --password options
- `mokosuitebackup:snapshot` with create, restore, list, delete actions
- REST API for snapshots: create, list, restore, delete, download
- Profile credentials masked in API responses
### Added — Notifications & Logging
- Email/ntfy notifications for site restore, snapshot create/restore
- Joomla Action Logs for restore, snapshot, and snapshot restore events
- Global ntfy server/topic/token settings (fallback for profiles)
### Added — Security & Configuration
- Webcron secret field with CSPRNG generator + strength meter
- IP whitelist field with current IP detection + one-click "Add my IP"
- 10 ACL permissions with full enforcement audit across all controllers
- Config defaults: archive format, MokoRestore mode, sanitization settings
- Path traversal protection on all archive extraction (ZIP, tar.gz, JPA)
### Fixed
- CLI RestoreCommand passed wrong arguments (filepath instead of record ID)
- JPA path traversal: reject `../` in archive entry paths
- S3Uploader OOM: streaming upload instead of file_get_contents
- DatabaseDumper OOM: streaming to file instead of in-memory string
- AkeebaImporter: removed unserialize() (PHP object injection risk)
- BackupTable: delete DB row before file (prevents data loss)
- RestoreEngine: staging path sanitized with preg_replace
- API profiles: sensitive fields masked with `***`
- Webcron: missing return after sendJsonResponse on auth failure
- loadFormData(): cast array to object (PHP 8.x TypeError fix)
- MokoRestore data-only mode: uses REPLACE INTO for existing rows
- Plaintext archive deleted on encryption failure
- TarGzArchiver: intermediate .tar cleaned in finally block
- Install script: single-line comments converted to block comments
- Orphaned root-level webservices plugin files removed
- include_mokorestore column: TINYINT changed to VARCHAR(20)
- Snapshot fields_values: scoped dump and restore to com_content.article (previously destroyed values for contacts, users, etc.)
- Run Backup button: accept CSRF token from GET (fixes "token did not match" on profile edit)
- SFTP fields: moved into remote fieldset for showon visibility; removed required attr that blocked non-SFTP saves
- Script.php merge conflict markers resolved
## [01.24.00] — 2026-06-02
### Added
- Initial release: full-site backup and restore for Joomla 6
- Database, files, and configuration backup
- ZIP and tar.gz archive formats with AES-256 encryption
- Differential backups based on file manifests
- FTP/FTPS, S3, Google Drive remote storage
- MokoRestore standalone restore wizard
- CLI backup and restore commands
- REST API for remote management
- Scheduled tasks via com_scheduler
- Email and ntfy push notifications
- Per-profile retention, exclusions, and notifications
- Akeeba Backup migration tool
- Admin dashboard with system health checks
+1 -1
View File
@@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla
INGROUP: Template-Joomla.Documentation INGROUP: Template-Joomla.Documentation
REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla
PATH: /SECURITY.md PATH: /SECURITY.md
VERSION: 01.44.01 VERSION: 02.52.24
BRIEF: Security vulnerability reporting and handling policy BRIEF: Security vulnerability reporting and handling policy
--> -->
@@ -15,5 +15,6 @@
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" /> <action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" /> <action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" /> <action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
<action name="mokosuitebackup.backup.cancel" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL" />
</section> </section>
</access> </access>
@@ -15,6 +15,7 @@
> >
<option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option> <option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option>
<option value="complete">COM_MOKOJOOMBACKUP_STATUS_COMPLETE</option> <option value="complete">COM_MOKOJOOMBACKUP_STATUS_COMPLETE</option>
<option value="warning">COM_MOKOJOOMBACKUP_STATUS_WARNING</option>
<option value="running">COM_MOKOJOOMBACKUP_STATUS_RUNNING</option> <option value="running">COM_MOKOJOOMBACKUP_STATUS_RUNNING</option>
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option> <option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option> <option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
@@ -207,6 +207,7 @@ COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)"
; Status labels ; Status labels
COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete" COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete"
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running" COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running"
COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed" COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed"
COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending" COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
@@ -450,6 +451,8 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE="Compare Backups"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side." COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives" COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting." COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
; Snapshot ACL ; Snapshot ACL
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots" COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
@@ -500,6 +503,12 @@ COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date.
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully." COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted." COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
; Cancel Stalled Backup
COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED="Cancel Stalled"
COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED="No backup records selected."
COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING="None of the selected backups are in running status."
COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled."
; Remote Destinations (multi-remote) ; Remote Destinations (multi-remote)
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations" COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination" COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
@@ -116,3 +116,16 @@ COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selec
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date." COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully." COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted." COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
; Cancel Stalled Backup
COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED="Cancel Stalled"
COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED="No backup records selected."
COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING="None of the selected backups are in running status."
COM_MOKOJOOMBACKUP_CANCEL_SUCCESS="%d stalled backup(s) cancelled."
; Backup status
COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning"
; ACL - Cancel
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL="Cancel Stalled Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_CANCEL_DESC="Allows users to cancel backup records stuck in running status and clean up partial archive files."
@@ -7,7 +7,7 @@
--> -->
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>MokoSuiteBackup</name> <name>MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -65,7 +65,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1, `profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
`description` VARCHAR(255) NOT NULL DEFAULT '', `description` VARCHAR(255) NOT NULL DEFAULT '',
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, fail', `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, warning, fail',
`origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled', `origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled',
`backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files', `backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files',
`archivename` VARCHAR(512) NOT NULL DEFAULT '', `archivename` VARCHAR(512) NOT NULL DEFAULT '',
@@ -0,0 +1 @@
/* 01.45.00 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.16 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.17 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.18 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.20 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.21 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.22 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.23 — no schema changes */
@@ -0,0 +1 @@
/* 02.52.24 — no schema changes */
@@ -84,6 +84,67 @@ class AjaxController extends BaseController
$this->sendJson($result); $this->sendJson($result);
} }
/**
* Cancel a backup record stuck in "running" status.
* POST: task=ajax.cancelBackup&id=123
*/
public function cancelBackup(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$id = $this->input->getInt('id', 0);
if (!$id) {
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
return;
}
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'status', 'absolute_path']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
return;
}
if ($record->status !== 'running') {
$this->sendJson(['error' => true, 'message' => 'Backup is not in running status']);
return;
}
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_records'))
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($update);
$db->execute();
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$this->sendJson(['error' => false, 'message' => 'Backup cancelled']);
}
/** /**
* Browse server directories for the folder picker field. * Browse server directories for the folder picker field.
* POST: task=ajax.browseDir&path=/some/path * POST: task=ajax.browseDir&path=/some/path
@@ -451,7 +512,7 @@ class AjaxController extends BaseController
return; return;
} }
if ($record->status !== 'complete' || !$record->filesexist) { if (!\in_array($record->status, ['complete', 'warning'], true) || !$record->filesexist) {
$this->sendJson(['error' => true, 'message' => 'Archive not available']); $this->sendJson(['error' => true, 'message' => 'Archive not available']);
return; return;
@@ -747,7 +808,7 @@ class AjaxController extends BaseController
->select('COUNT(*)') ->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff)) ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete')); ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
$db->setQuery($query); $db->setQuery($query);
$count = (int) $db->loadResult(); $count = (int) $db->loadResult();
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -199,7 +199,7 @@ class BackupsController extends AdminController
->select($db->quoteName('id')) ->select($db->quoteName('id'))
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff)) ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete')); ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
$db->setQuery($query); $db->setQuery($query);
$ids = $db->loadColumn(); $ids = $db->loadColumn();
@@ -235,6 +235,76 @@ class BackupsController extends AdminController
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false)); $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
} }
/**
* Cancel selected backup records that are stuck in "running" status.
*
* Sets their status to "fail", cleans up partial archive files,
* and destroys any associated stepped session.
*/
public function cancelStalled(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$cid = $this->input->get('cid', [], 'array');
if (empty($cid)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_CANCEL_NONE_SELECTED'), 'warning');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$db = $this->app->getContainer()->get('DatabaseDriver');
$cancelled = 0;
$skipped = 0;
foreach ($cid as $id) {
$id = (int) $id;
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'status', 'absolute_path']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record || $record->status !== 'running') {
$skipped++;
continue;
}
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_records'))
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($update);
$db->execute();
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$cancelled++;
}
if ($cancelled > 0) {
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_CANCEL_SUCCESS', $cancelled));
} elseif ($skipped > 0) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_CANCEL_NONE_RUNNING'), 'warning');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/** /**
* No-op target for the purge toolbar button. * No-op target for the purge toolbar button.
* *
@@ -375,7 +375,7 @@ class BackupEngine
// Final record update (includes fields needed by NotificationSender) // Final record update (includes fields needed by NotificationSender)
$update = (object) [ $update = (object) [
'id' => $recordId, 'id' => $recordId,
'status' => 'complete', 'status' => $uploadFailed ? 'warning' : 'complete',
'description' => $description, 'description' => $description,
'backup_type' => $profile->backup_type, 'backup_type' => $profile->backup_type,
'archivename' => $archiveName, 'archivename' => $archiveName,
@@ -547,7 +547,16 @@ class BackupEngine
*/ */
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{ {
$fake = (object) $params; $prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
$prefix = $prefixMap[$type] ?? '';
$prefixed = [];
foreach ($params as $key => $value) {
$prefixed[$prefix . $key] = $value;
}
$fake = (object) $prefixed;
return match ($type) { return match ($type) {
'ftp' => new FtpUploader($fake), 'ftp' => new FtpUploader($fake),
@@ -597,7 +606,7 @@ class BackupEngine
->select($db->quoteName('manifest')) ->select($db->quoteName('manifest'))
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $profileId) ->where($db->quoteName('profile_id') . ' = ' . $profileId)
->where($db->quoteName('status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->where($db->quoteName('manifest') . ' != ' . $db->quote('')) ->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
->where($db->quoteName('backup_type') . ' = ' . $db->quote('full')) ->where($db->quoteName('backup_type') . ' = ' . $db->quote('full'))
->order($db->quoteName('backupstart') . ' DESC'); ->order($db->quoteName('backupstart') . ' DESC');
@@ -346,6 +346,9 @@ define('MOKOJOOMBACKUP_RESTORE', 1);
define('RESTORE_DIR', __DIR__); define('RESTORE_DIR', __DIR__);
define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip'); define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');
error_log('MokoRestore: Script loaded — RESTORE_DIR=' . RESTORE_DIR);
error_log('MokoRestore: PHP ' . PHP_VERSION . ', SAPI=' . php_sapi_name() . ', memory_limit=' . ini_get('memory_limit'));
session_start(); session_start();
if (empty($_SESSION['restore_token'])) { if (empty($_SESSION['restore_token'])) {
@@ -358,25 +361,37 @@ $token = $_SESSION['restore_token'];
// Write a security file to the web root with a random code. // Write a security file to the web root with a random code.
// The user must read the code from the file and enter it in the browser // The user must read the code from the file and enter it in the browser
// to prove they have filesystem access before any restore actions are allowed. // to prove they have filesystem access before any restore actions are allowed.
$securityFile = RESTORE_DIR . '/.mokorestore-security.php'; $securityFile = RESTORE_DIR . '/mokorestore-security.php';
$securityCode = $_SESSION['security_code'] ?? ''; $securityCode = $_SESSION['security_code'] ?? '';
if (empty($securityCode)) { if (empty($securityCode)) {
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)); $securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$_SESSION['security_code'] = $securityCode; $_SESSION['security_code'] = $securityCode;
$_SESSION['security_verified'] = false; $_SESSION['security_verified'] = false;
}
// Write (or recreate) the security file whenever verification is still pending
if (empty($_SESSION['security_verified']) && !is_file($securityFile)) {
error_log('MokoRestore: Writing security file: ' . $securityFile);
error_log('MokoRestore: Target directory: ' . RESTORE_DIR . ' (writable: ' . (is_writable(RESTORE_DIR) ? 'yes' : 'NO') . ')');
// Write security file with the code
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n" $securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
. "MokoRestore Security Verification\n" . "MokoRestore Security Verification\n"
. "==================================\n" . "==================================\n"
. "Code: " . $securityCode . "\n" . "Code: " . $securityCode . "\n"
. "Enter this code in the MokoRestore browser interface to proceed.\n" . "Enter this code in the MokoRestore browser interface to proceed.\n"
. "This file will be deleted automatically after verification.\n"; . "This file will be deleted automatically after verification.\n";
if (file_put_contents($securityFile, $securityContent) === false) {
// Cannot write security file — skip verification to avoid locking user out $written = @file_put_contents($securityFile, $securityContent);
if ($written === false) {
$err = error_get_last();
error_log('MokoRestore: FAILED to write security file — ' . ($err['message'] ?? 'unknown error'));
error_log('MokoRestore: Directory permissions: ' . decoct(@fileperms(RESTORE_DIR) & 0777) . ', owner: ' . @fileowner(RESTORE_DIR) . ', PHP user: ' . (function_exists('posix_getuid') ? posix_getuid() : 'n/a'));
error_log('MokoRestore: Security verification SKIPPED — user will not be challenged');
$_SESSION['security_verified'] = true; $_SESSION['security_verified'] = true;
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)'); } else {
error_log('MokoRestore: Security file created (' . $written . ' bytes)');
} }
} }
@@ -387,15 +402,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['
if ($inputCode === $securityCode) { if ($inputCode === $securityCode) {
$_SESSION['security_verified'] = true; $_SESSION['security_verified'] = true;
error_log('MokoRestore: Security code VERIFIED');
// Delete the security file
if (is_file($securityFile)) { if (is_file($securityFile)) {
@unlink($securityFile); @unlink($securityFile);
error_log('MokoRestore: Security file deleted');
} }
echo json_encode(['success' => true, 'message' => 'Security verified']); echo json_encode(['success' => true, 'message' => 'Security verified']);
} else { } else {
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']); error_log('MokoRestore: Security code REJECTED (input=' . $inputCode . ')');
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: mokorestore-security.php']);
} }
exit; exit;
@@ -414,7 +431,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
} }
if (!$securityVerified) { if (!$securityVerified) {
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']); echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from mokorestore-security.php']);
exit; exit;
} }
@@ -424,9 +441,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
@ignore_user_abort(true); @ignore_user_abort(true);
try { try {
error_log('MokoRestore: Action dispatched — ' . $_POST['action']);
$result = handleAction($_POST['action'], $_POST); $result = handleAction($_POST['action'], $_POST);
error_log('MokoRestore: Action ' . $_POST['action'] . ' completed — ' . ($result['success'] ? 'OK' : 'FAIL: ' . ($result['message'] ?? '')));
echo json_encode($result); echo json_encode($result);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log('MokoRestore: Action ' . $_POST['action'] . ' EXCEPTION — ' . $e->getMessage());
echo json_encode(['success' => false, 'message' => $e->getMessage()]); echo json_encode(['success' => false, 'message' => $e->getMessage()]);
} }
@@ -551,10 +571,14 @@ function actionPreflight(): array
function actionExtract(array $data): array function actionExtract(array $data): array
{ {
error_log('MokoRestore: Extract — target=' . BACKUP_FILE . ', exists=' . (file_exists(BACKUP_FILE) ? 'yes' : 'no'));
if (!file_exists(BACKUP_FILE)) { if (!file_exists(BACKUP_FILE)) {
throw new RuntimeException('Backup file not found: site-backup.zip'); throw new RuntimeException('Backup file not found: site-backup.zip');
} }
error_log('MokoRestore: Extract — archive size=' . number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB');
$zip = new ZipArchive(); $zip = new ZipArchive();
if ($zip->open(BACKUP_FILE) !== true) { if ($zip->open(BACKUP_FILE) !== true) {
@@ -591,6 +615,8 @@ function actionExtract(array $data): array
$count = $zip->numFiles; $count = $zip->numFiles;
$zip->close(); $zip->close();
error_log('MokoRestore: Extract — ' . $count . ' files extracted to ' . RESTORE_DIR);
// Pre-fill from configuration.php.bak (sanitized backup) or // Pre-fill from configuration.php.bak (sanitized backup) or
// configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values. // configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values.
$existingConfig = []; $existingConfig = [];
@@ -719,6 +745,8 @@ function actionDatabase(array $data): array
$user = $data['db_user'] ?? ''; $user = $data['db_user'] ?? '';
$pass = $data['db_pass'] ?? ''; $pass = $data['db_pass'] ?? '';
error_log('MokoRestore: Database import — host=' . $host . ', db=' . $name . ', user=' . $user);
if (empty($name) || empty($user)) { if (empty($name) || empty($user)) {
throw new RuntimeException('Database name and user are required'); throw new RuntimeException('Database name and user are required');
} }
@@ -726,9 +754,12 @@ function actionDatabase(array $data): array
$sqlFile = RESTORE_DIR . '/database.sql'; $sqlFile = RESTORE_DIR . '/database.sql';
if (!is_file($sqlFile)) { if (!is_file($sqlFile)) {
error_log('MokoRestore: Database import — no database.sql found, skipping');
return ['success' => true, 'message' => 'No database.sql found — skipped', 'statements' => 0, 'errors' => 0]; return ['success' => true, 'message' => 'No database.sql found — skipped', 'statements' => 0, 'errors' => 0];
} }
error_log('MokoRestore: Database import — SQL file size=' . number_format(filesize($sqlFile) / 1048576, 2) . ' MB');
$pdo = new PDO( $pdo = new PDO(
"mysql:host={$host};dbname={$name};charset=utf8mb4", "mysql:host={$host};dbname={$name};charset=utf8mb4",
$user, $user,
@@ -835,6 +866,14 @@ function actionDatabase(array $data): array
$msg .= " ({$errors} warnings)"; $msg .= " ({$errors} warnings)";
} }
error_log('MokoRestore: Database import — ' . $msg);
if (!empty($errorList)) {
foreach ($errorList as $i => $err) {
error_log('MokoRestore: DB error ' . ($i + 1) . ': ' . $err);
}
}
return [ return [
'success' => ($statements > 0 || $errors === 0), 'success' => ($statements > 0 || $errors === 0),
'message' => $msg, 'message' => $msg,
@@ -847,6 +886,7 @@ function actionDatabase(array $data): array
function actionConfig(array $data): array function actionConfig(array $data): array
{ {
error_log('MokoRestore: Config rebuild started');
$host = $data['db_host'] ?? 'localhost'; $host = $data['db_host'] ?? 'localhost';
$dbName = $data['db_name'] ?? ''; $dbName = $data['db_name'] ?? '';
$dbUser = $data['db_user'] ?? ''; $dbUser = $data['db_user'] ?? '';
@@ -867,6 +907,7 @@ function actionConfig(array $data): array
// debug, cache, SEF, editor, etc.). Fall back to existing config // debug, cache, SEF, editor, etc.). Fall back to existing config
// for legacy/unsanitized backups, or build from scratch if neither exists. // for legacy/unsanitized backups, or build from scratch if neither exists.
$basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null); $basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null);
error_log('MokoRestore: Config — base template: ' . ($basePath ?? 'none (building from scratch)'));
if ($basePath !== null) { if ($basePath !== null) {
$config = file_get_contents($basePath); $config = file_get_contents($basePath);
@@ -919,9 +960,12 @@ function actionConfig(array $data): array
} }
if (file_put_contents($configPath, $config) === false) { if (file_put_contents($configPath, $config) === false) {
error_log('MokoRestore: Config — FAILED to write ' . $configPath);
return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions']; return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions'];
} }
error_log('MokoRestore: Config — written to ' . $configPath . ' (' . filesize($configPath) . ' bytes)');
// Remove .bak after successful rebuild // Remove .bak after successful rebuild
if (is_file($bakPath)) { if (is_file($bakPath)) {
@unlink($bakPath); @unlink($bakPath);
@@ -1175,6 +1219,8 @@ function actionResetAdmin(array $data): array
$userId = (int) ($data['admin_id'] ?? 0); $userId = (int) ($data['admin_id'] ?? 0);
$password = $data['new_password'] ?? ''; $password = $data['new_password'] ?? '';
error_log('MokoRestore: Admin password reset — user_id=' . $userId);
if ($userId < 1 || strlen($password) < 8) { if ($userId < 1 || strlen($password) < 8) {
throw new RuntimeException('Select an admin and enter a password (8+ characters)'); throw new RuntimeException('Select an admin and enter a password (8+ characters)');
} }
@@ -1188,6 +1234,7 @@ function actionResetAdmin(array $data): array
throw new RuntimeException('User not found or password unchanged'); throw new RuntimeException('User not found or password unchanged');
} }
error_log('MokoRestore: Admin password reset — success');
return ['success' => true, 'message' => 'Admin password updated successfully']; return ['success' => true, 'message' => 'Admin password updated successfully'];
} }
@@ -1197,6 +1244,7 @@ function actionPostRestore(array $data): array
$prefix = getValidatedPrefix($data); $prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; $tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = []; $results = [];
error_log('MokoRestore: Post-restore — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
foreach ($tasks as $task) { foreach ($tasks as $task) {
try { try {
@@ -1319,6 +1367,7 @@ function actionProvision(array $data): array
$prefix = getValidatedPrefix($data); $prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; $tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = []; $results = [];
error_log('MokoRestore: Provisioning — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks));
foreach ($tasks as $task) { foreach ($tasks as $task) {
try { try {
@@ -1395,15 +1444,23 @@ function actionProvision(array $data): array
function actionCleanup(): array function actionCleanup(): array
{ {
error_log('MokoRestore: Cleanup started');
$removed = []; $removed = [];
foreach (['database.sql', 'site-backup.zip'] as $file) { foreach (['database.sql', 'site-backup.zip', 'mokorestore-security.php'] as $file) {
$path = RESTORE_DIR . '/' . $file; $path = RESTORE_DIR . '/' . $file;
if (is_file($path) && @unlink($path)) { if (is_file($path)) {
if (@unlink($path)) {
$removed[] = $file; $removed[] = $file;
error_log('MokoRestore: Cleanup — removed ' . $file);
} else {
error_log('MokoRestore: Cleanup — FAILED to remove ' . $file);
} }
} }
}
error_log('MokoRestore: Cleanup complete — removed ' . count($removed) . ' file(s)');
return [ return [
'success' => true, 'success' => true,
@@ -1570,14 +1627,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<!-- Step 0: Security Verification --> <!-- Step 0: Security Verification -->
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0"> <div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2> <h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p> <p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc"> <div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem"> <div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code <span style="font-size:1.1rem">&#128274;</span> How to find the code
</div> </div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6"> <ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li> <li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li> <li>Open <code>mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li> <li>Copy the 8-character code and enter it below</li>
</ol> </ol>
</div> </div>
@@ -165,7 +165,7 @@ class PreflightCheck
->select($db->quoteName('total_size')) ->select($db->quoteName('total_size'))
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->where($db->quoteName('total_size') . ' > 0') ->where($db->quoteName('total_size') . ' > 0')
->order($db->quoteName('backupstart') . ' DESC'); ->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query, 0, 1); $db->setQuery($query, 0, 1);
@@ -194,22 +194,58 @@ class PreflightCheck
} }
} }
private const STALE_TIMEOUT_MINUTES = 30;
/** /**
* Check if another backup is already running for this profile. * Check if another backup is already running for this profile.
*
* Backups running longer than STALE_TIMEOUT_MINUTES are automatically
* marked as failed so they don't permanently block future runs.
*/ */
private function checkRunningBackup(object $profile, object $db): void private function checkRunningBackup(object $profile, object $db): void
{ {
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select('COUNT(*)') ->select($db->quoteName(['id', 'backupstart', 'absolute_path']))
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('running')); ->where($db->quoteName('status') . ' = ' . $db->quote('running'));
$db->setQuery($query); $db->setQuery($query);
$running = (int) $db->loadResult(); $rows = $db->loadObjectList();
if ($running > 0) { if (empty($rows)) {
return;
}
$cutoff = time() - (self::STALE_TIMEOUT_MINUTES * 60);
$stillAlive = 0;
foreach ($rows as $row) {
$started = strtotime($row->backupstart);
if ($started !== false && $started < $cutoff) {
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_records'))
->set($db->quoteName('status') . ' = ' . $db->quote('fail'))
->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . (int) $row->id);
$db->setQuery($update);
$db->execute();
if (!empty($row->absolute_path) && is_file($row->absolute_path)) {
@unlink($row->absolute_path);
}
$this->warnings[] = 'Auto-cancelled stalled backup #' . $row->id
. ' (started ' . $row->backupstart . ', exceeded '
. self::STALE_TIMEOUT_MINUTES . ' min timeout)';
} else {
$stillAlive++;
}
}
if ($stillAlive > 0) {
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title $this->errors[] = 'Another backup is already running for profile: ' . $profile->title
. ' — wait for it to finish or delete the stale record'; . ' — wait for it to finish or use Cancel Stalled from the Backup Records toolbar';
} }
} }
@@ -67,7 +67,7 @@ class RestoreEngine
return ['success' => false, 'message' => 'Backup record not found: ' . $recordId]; return ['success' => false, 'message' => 'Backup record not found: ' . $recordId];
} }
if ($record->status !== 'complete') { if ($record->status !== 'complete' && $record->status !== 'warning') {
return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')']; return ['success' => false, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
} }
@@ -647,7 +647,7 @@ class SteppedBackupEngine
$update = (object) [ $update = (object) [
'id' => $session->recordId, 'id' => $session->recordId,
'status' => 'complete', 'status' => $uploadFailed ? 'warning' : 'complete',
'backupend' => date('Y-m-d H:i:s'), 'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize, 'total_size' => $totalSize,
'checksum' => $checksum, 'checksum' => $checksum,
@@ -889,7 +889,16 @@ class SteppedBackupEngine
*/ */
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{ {
$fake = (object) $params; $prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_'];
$prefix = $prefixMap[$type] ?? '';
$prefixed = [];
foreach ($params as $key => $value) {
$prefixed[$prefix . $key] = $value;
}
$fake = (object) $prefixed;
return match ($type) { return match ($type) {
'ftp' => new FtpUploader($fake), 'ftp' => new FtpUploader($fake),
@@ -64,7 +64,7 @@ class SteppedRestoreEngine
return ['error' => true, 'message' => 'Backup record not found: ' . $recordId]; return ['error' => true, 'message' => 'Backup record not found: ' . $recordId];
} }
if ($record->status !== 'complete') { if ($record->status !== 'complete' && $record->status !== 'warning') {
return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')']; return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')'];
} }
@@ -70,7 +70,7 @@ class BackupStatusHelper
]) ])
->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id') ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')') ->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning', 'fail'])) . ')')
->order($db->quoteName('r.backupstart') . ' DESC'); ->order($db->quoteName('r.backupstart') . ' DESC');
if ($profileId !== null) { if ($profileId !== null) {
@@ -148,7 +148,7 @@ class BackupStatusHelper
$query = $db->getQuery(true) $query = $db->getQuery(true)
->select($db->quoteName('status')) ->select($db->quoteName('status'))
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')') ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning', 'fail'])) . ')')
->order($db->quoteName('backupstart') . ' DESC') ->order($db->quoteName('backupstart') . ' DESC')
->setLimit(50); ->setLimit(50);
@@ -156,7 +156,7 @@ class BackupStatusHelper
$streak = 0; $streak = 0;
foreach ($statuses as $s) { foreach ($statuses as $s) {
if ($s === 'complete') { if ($s === 'complete' || $s === 'warning') {
$streak++; $streak++;
} else { } else {
break; break;
@@ -30,7 +30,7 @@ class DashboardModel extends BaseDatabaseModel
->select('r.*, p.title AS profile_title') ->select('r.*, p.title AS profile_title')
->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id') ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->order($db->quoteName('r.backupend') . ' DESC'); ->order($db->quoteName('r.backupend') . ' DESC');
$db->setQuery($query, 0, 1); $db->setQuery($query, 0, 1);
@@ -75,7 +75,7 @@ class DashboardModel extends BaseDatabaseModel
->select('COUNT(*) AS total_count') ->select('COUNT(*) AS total_count')
->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size') ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
->from($db->quoteName('#__mokosuitebackup_records')) ->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete')); ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')');
$db->setQuery($query); $db->setQuery($query);
$stats = $db->loadObject(); $stats = $db->loadObject();
@@ -274,7 +274,7 @@ class DashboardModel extends BaseDatabaseModel
->select('COALESCE(SUM(r.total_size), 0) AS total_size') ->select('COALESCE(SUM(r.total_size), 0) AS total_size')
->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id') ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')')
->group($db->quoteName('r.profile_id')) ->group($db->quoteName('r.profile_id'))
->order('total_size DESC'); ->order('total_size DESC');
$db->setQuery($query); $db->setQuery($query);
@@ -41,7 +41,7 @@ class HtmlView extends BaseHtmlView
$user = Factory::getApplication()->getIdentity(); $user = Factory::getApplication()->getIdentity();
if ($this->item->status === 'complete' if (\in_array($this->item->status, ['complete', 'warning'], true)
&& !empty($this->item->filesexist) && !empty($this->item->filesexist)
&& $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup') && $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')
) { ) {
@@ -113,6 +113,10 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true); ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
} }
if ($user->authorise('mokosuitebackup.backup.cancel', 'com_mokosuitebackup')) {
ToolbarHelper::custom('backups.cancelStalled', 'stop-circle', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_CANCEL_STALLED', true);
}
if ($user->authorise('core.delete', 'com_mokosuitebackup')) { if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
} }
@@ -30,6 +30,7 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
<?php <?php
$statusClass = match ($this->item->status) { $statusClass = match ($this->item->status) {
'complete' => 'badge bg-success', 'complete' => 'badge bg-success',
'warning' => 'badge bg-warning text-dark',
'running' => 'badge bg-info', 'running' => 'badge bg-info',
'fail' => 'badge bg-danger', 'fail' => 'badge bg-danger',
default => 'badge bg-secondary', default => 'badge bg-secondary',
@@ -92,6 +92,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<?php <?php
$statusClass = match ($item->status) { $statusClass = match ($item->status) {
'complete' => 'badge bg-success', 'complete' => 'badge bg-success',
'warning' => 'badge bg-warning text-dark',
'running' => 'badge bg-info', 'running' => 'badge bg-info',
'fail' => 'badge bg-danger', 'fail' => 'badge bg-danger',
default => 'badge bg-secondary', default => 'badge bg-secondary',
@@ -11,6 +11,7 @@ MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED="MokoSuiteBackup is not installed or is
MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup" MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success" MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_WARNING="Warning"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed" MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed"
MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet." MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet."
MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables" MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables"
@@ -8,7 +8,7 @@
--> -->
<extension type="module" client="administrator" method="upgrade"> <extension type="module" client="administrator" method="upgrade">
<name>mod_mokosuitebackup_cpanel</name> <name>mod_mokosuitebackup_cpanel</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-23</creationDate> <creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -51,10 +51,20 @@ $moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
<?php if ($latest) : ?> <?php if ($latest) : ?>
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div> <div>
<span class="badge <?php echo $latest['status'] === 'complete' ? 'bg-success' : 'bg-danger'; ?>"> <?php
<?php echo $latest['status'] === 'complete' $cpanelBadge = match ($latest['status']) {
? Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK') 'complete' => 'bg-success',
: Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'); ?> 'warning' => 'bg-warning text-dark',
default => 'bg-danger',
};
$cpanelLabel = match ($latest['status']) {
'complete' => Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK'),
'warning' => Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_WARNING'),
default => Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'),
};
?>
<span class="badge <?php echo $cpanelBadge; ?>">
<?php echo $cpanelLabel; ?>
</span> </span>
<span class="ms-1 small text-muted"> <span class="ms-1 small text-muted">
<?php echo htmlspecialchars($latest['profile'] ?? ''); ?> <?php echo htmlspecialchars($latest['profile'] ?? ''); ?>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="actionlog" method="upgrade"> <extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name> <name>Action Log - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="console" method="upgrade"> <extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name> <name>Console - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name> <name>Content - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade"> <extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name> <name>Quick Icon - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name> <name>System - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name> <name>Task - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="webservices" method="upgrade"> <extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name> <name>Web Services - MokoSuiteBackup</name>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name> <name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename> <packagename>mokosuitebackup</packagename>
<version>01.44.01</version> <version>02.52.24</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>