diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 4489ae0..8337c71 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -64,10 +64,14 @@ jobs: promote-rc: name: Promote to RC runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - (github.event.action == 'opened' && github.event.pull_request.merged != true) || - (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + !startsWith(github.event.repository.name, 'Template-') && + ( + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + ) steps: - name: Checkout repository @@ -164,9 +168,13 @@ jobs: release: name: Build & Release Pipeline runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + !startsWith(github.event.repository.name, 'Template-') && + ( + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + ) steps: - name: Checkout repository diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml index 9d884e7..dd5ff56 100644 --- a/.mokogitea/workflows/branch-cleanup.yml +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -33,7 +33,8 @@ jobs: run: | BRANCH="${{ github.event.pull_request.head.ref }}" API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" - ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + # URL-encode the branch name's slashes (no PHP dependency on the runner) + ENCODED=$(printf '%s' "${BRANCH}" | sed 's|/|%2F|g') STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ diff --git a/.mokogitea/workflows/ci-generic.yml b/.mokogitea/workflows/ci-generic.yml index 92d2685..72650d2 100644 --- a/.mokogitea/workflows/ci-generic.yml +++ b/.mokogitea/workflows/ci-generic.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.CI # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic -# PATH: /.gitea/workflows/ci-generic.yml +# PATH: /.mokogitea/workflows/ci-generic.yml # VERSION: 01.00.00 # BRIEF: CI pipeline — lint, validate, and test for generic projects (PHP + Node.js) @@ -32,6 +32,8 @@ jobs: lint: name: Lint & Validate runs-on: ubuntu-latest + # Skip on template repos (Template-*) — they hold placeholder scaffolding, not buildable source. + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -130,6 +132,9 @@ jobs: name: Tests runs-on: ubuntu-latest needs: lint + # Run only when lint succeeded; always() forces evaluation so a skipped + # lint (e.g. template repos) skips this job cleanly instead of hanging. + if: ${{ always() && needs.lint.result == 'success' }} steps: - name: Checkout diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 0023862..2e6b6a2 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.gitea/workflows/cleanup.yml +# PATH: /.mokogitea/workflows/cleanup.yml # VERSION: 01.00.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index 51dfcb5..9dfa047 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -6,7 +6,7 @@ # DEFGROUP: Gitea.Workflow # INGROUP: MokoStandards.Notifications # REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards -# PATH: /.gitea/workflows/notify.yml +# PATH: /.mokogitea/workflows/notify.yml # VERSION: 01.00.00 # BRIEF: Push notifications via ntfy on release success or workflow failure diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index c834bf5..019371d 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -4,8 +4,8 @@ # # FILE INFORMATION # DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.CI -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# INGROUP: mokocli.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # PATH: /templates/workflows/universal/pr-check.yml.template # VERSION: 09.23.00 # BRIEF: PR gate — branch policy + code validation before merge @@ -47,15 +47,15 @@ jobs: fi ;; fix/*|bugfix/*) - if [ "$BASE" != "dev" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then ALLOWED=false - REASON="Fix branches must target 'dev', not '${BASE}'" + REASON="Fix branches must target 'dev' or 'main', not '${BASE}'" fi ;; patch/*) - if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then ALLOWED=false - REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'" fi ;; hotfix/*) @@ -86,7 +86,8 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY - echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY @@ -126,6 +127,8 @@ jobs: validate: name: Validate PR runs-on: ubuntu-latest + # Skip on template repos (Template-*) — no real manifest/source/changelog to validate. + if: ${{ !startsWith(github.event.repository.name, 'Template-') }} steps: - name: Checkout @@ -147,11 +150,12 @@ jobs: - name: Detect platform id: platform run: | - # Read platform from XML manifest ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) - [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') + # Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used. + API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata" + PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)" [ -z "$PLATFORM" ] && PLATFORM="generic" echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + echo "Detected platform: $PLATFORM" - name: Setup PHP if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' @@ -492,6 +496,9 @@ jobs: name: Build RC Package runs-on: ubuntu-latest needs: [branch-policy, validate] + # Run only when both gates succeeded; always() forces evaluation so a skipped + # validate (e.g. template repos) skips this job cleanly instead of hanging. + if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }} steps: - name: Trigger RC pre-release diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml index efb3d1b..cc40025 100644 --- a/.mokogitea/workflows/pre-release.yml +++ b/.mokogitea/workflows/pre-release.yml @@ -48,9 +48,13 @@ jobs: build: name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" runs-on: release + # Skip on template repos (Template-*) — they scaffold other repos and do not release. if: >- - github.event_name == 'workflow_dispatch' || - github.event_name == 'push' + !startsWith(github.event.repository.name, 'Template-') && + ( + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' + ) steps: - name: Checkout diff --git a/.mokogitea/workflows/sync-on-merge.yml b/.mokogitea/workflows/sync-on-merge.yml new file mode 100644 index 0000000..1b882bc --- /dev/null +++ b/.mokogitea/workflows/sync-on-merge.yml @@ -0,0 +1,31 @@ +name: Sync Workflows to Repos + +on: + push: + branches: + - main + paths: + - '.mokogitea/workflows/**' + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout mokocli + uses: actions/checkout@v4 + with: + repository: MokoConsulting/mokocli + token: ${{ secrets.MOKOGITEA_TOKEN }} + + - name: Setup PHP + uses: https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/raw/branch/main/actions/setup-php@v1 + with: + php-version: '8.1' + + - name: Install dependencies + run: composer install --no-dev --no-interaction + + - name: Sync workflows to generic repos + run: php automation/bulk_sync.php --platform generic --org MokoConsulting --workflows-only --auto-merge --token "${{ secrets.MOKOGITEA_TOKEN }}" + env: + MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ef16d..9786647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,14 @@ # Changelog ## [Unreleased] -## [02.52.24] --- 2026-06-30 +## [02.55.00] --- 2026-07-04 +## [02.55.00] --- 2026-07-04 -## [02.52.24] --- 2026-06-30 +## [02.54.00] --- 2026-07-04 -## [02.52.22] --- 2026-06-30 +## [02.54.00] --- 2026-07-04 -### 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 +## [02.54.00] --- 2026-07-04 -### 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 - -### Added -- Customizable restore script filename per backup profile (reduces discoverability on remote servers) -- MokoRestore standalone mode: multi-ZIP selector when multiple backup archives are present -- MokoRestore preflight: Joomla installation detection warning before overwriting an existing site -- MokoRestore error handling: try/catch on fetch calls, HTTP status checks, JSON parse recovery -- Download button on individual backup record detail toolbar -- Profile column in backup records list links to the profile edit view - -### Changed -- Moved download, browse archive, and view log actions from backup list rows into the individual backup record view -- Removed "Run Backup" / "Backup Now" buttons from profiles list, profile edit toolbar, and backup records view (backups are triggered from the dashboard only) -- Removed ordering field from profiles; default sort is now by ID ascending -- MokoRestore cleanup and security messages now reference the actual script filename instead of hardcoded "restore.php" - -### Fixed -- SSH key indicator detection and missing delete language key -- Bootstrap 5 modal conversion for snapshots view (data-bs-dismiss, modal-footer, getOrCreateInstance) -- ntfy default URL changed from ntfy.sh to ntfy.mokoconsulting.tech -- Untranslated JFIELD_ORDERING_ASC / JFIELD_ORDERING_LABEL language keys replaced with component-specific keys -- 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 -- MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state +## [02.53.00] --- 2026-07-04 diff --git a/SECURITY.md b/SECURITY.md index 73cecfe..c3466da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla PATH: /SECURITY.md -VERSION: 02.52.25 +VERSION: 02.55.00 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/source/packages/MokoSuiteClient b/source/packages/MokoSuiteClient index bb0b6ec..9df6bea 160000 --- a/source/packages/MokoSuiteClient +++ b/source/packages/MokoSuiteClient @@ -1 +1 @@ -Subproject commit bb0b6ecac46a5c0a14606e16411121a9d158695b +Subproject commit 9df6bea4b7480b2e443898ad84a279070ba4a7f6 diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index c21b6d1..0e5e3c0 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -5,6 +5,7 @@ ; @license GPL-3.0-or-later COM_MOKOJOOMBACKUP="MokoSuiteBackup" +COM_MOKOJOOMBACKUP_SHORT="Backup" COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options" COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" @@ -22,7 +23,7 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup" COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site." ; Dashboard view -COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard" +COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="Dashboard" COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup" COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet" COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled" @@ -44,14 +45,14 @@ COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)" ; Backups view COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted." COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted." -COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" +COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Records" COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records" COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOJOOMBACKUP_DOWNLOAD="Download" ; Backup detail view -COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail" +COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Detail" COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log" COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents" COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name" @@ -75,7 +76,7 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size" COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path" ; Profiles view -COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" +COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles" COM_MOKOJOOMBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles" COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile" @@ -250,9 +251,9 @@ COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. ; Retention COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention" COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)" -COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 to use the global default from component options." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)." COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)" -COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 to use the global default from component options." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)." COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="Push Notifications (ntfy) — Send instant push notifications to your phone or desktop via ntfy.sh or a self-hosted ntfy server." COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic" diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.sys.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.sys.ini index d9887da..b7c62b0 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.sys.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.sys.ini @@ -5,6 +5,7 @@ ; @license GPL-3.0-or-later COM_MOKOJOOMBACKUP="MokoSuiteBackup" +COM_MOKOJOOMBACKUP_SHORT="Backup" COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard" COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini index fc50e39..2f31ce6 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -5,6 +5,7 @@ ; @license GPL-3.0-or-later COM_MOKOJOOMBACKUP="MokoSuiteBackup" +COM_MOKOJOOMBACKUP_SHORT="Backup" COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard" COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" @@ -18,7 +19,7 @@ COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to d COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup" COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site." -COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard" +COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="Dashboard" COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup" COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet" COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled" @@ -30,8 +31,8 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions" COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks" COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site" COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health" -COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" -COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" +COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Records" +COM_MOKOJOOMBACKUP_PROFILES_TITLE="Profiles" COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." @@ -133,3 +134,10 @@ COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted." ; 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." + +; Retention (per-profile) +COM_MOKOJOOMBACKUP_FIELDSET_RETENTION="Retention" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS="Keep Backups (days)" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_DAYS_DESC="Delete completed backups from this profile older than this many days. Set to 0 for unlimited (keep by age disabled)." +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT="Keep Backups (count)" +COM_MOKOJOOMBACKUP_FIELD_RETENTION_COUNT_DESC="Maximum number of completed backups to keep for this profile. Oldest are removed first. Set to 0 for unlimited (keep by count disabled)." diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.sys.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.sys.ini index 23f1d10..53dd3f7 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.sys.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.sys.ini @@ -5,6 +5,7 @@ ; @license GPL-3.0-or-later COM_MOKOJOOMBACKUP="MokoSuiteBackup" +COM_MOKOJOOMBACKUP_SHORT="Backup" COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard" COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index 61696e8..9784a0e 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -6,15 +6,15 @@ * @license GNU General Public License version 3 or later; see LICENSE --> - MokoSuiteBackup - 02.52.25 + Component - MokoSuiteBackup + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - COM_MOKOJOOMBACKUP_DESCRIPTION + Full-site backup and restore for Joomla — database, files, and configuration. Joomla\Component\MokoSuiteBackup @@ -37,20 +37,20 @@ - COM_MOKOJOOMBACKUP + Backup COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD + alt="Dashboard">Dashboard COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS + alt="Backups">Backup Records COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS + alt="Snapshots">Content Snapshots COM_MOKOJOOMBACKUP_SUBMENU_PROFILES + alt="Profiles">Backup Profiles access.xml diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 8b16d99..a2c23fd 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs', `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1, - `retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default', - `retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT '0 = use global default', + `retention_days` INT(11) NOT NULL DEFAULT 0 COMMENT 'Delete backups older than N days; 0 = unlimited', + `retention_count` INT(11) NOT NULL DEFAULT 0 COMMENT 'Keep newest N backups; 0 = unlimited', `ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name', `ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL', `ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)', diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.27.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.27.sql new file mode 100644 index 0000000..1adde24 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.27.sql @@ -0,0 +1 @@ +/* 02.52.27 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.53.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.53.00.sql new file mode 100644 index 0000000..d20c9cc --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.53.00.sql @@ -0,0 +1 @@ +/* 02.53.00 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.54.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.54.00.sql new file mode 100644 index 0000000..79f1e75 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.54.00.sql @@ -0,0 +1 @@ +/* 02.54.00 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.55.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.55.00.sql new file mode 100644 index 0000000..5278158 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.55.00.sql @@ -0,0 +1 @@ +/* 02.55.00 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index b0a8c92..d9105d3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -361,6 +361,17 @@ class BackupEngine NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log)); } + // Enforce per-profile retention (age and/or copy count). + try { + $pruned = RetentionManager::prune($db, $profile); + + if ($pruned > 0) { + $this->log('Retention: pruned ' . $pruned . ' old backup(s)'); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: retention pass failed: ' . $e->getMessage()); + } + // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); diff --git a/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php new file mode 100644 index 0000000..8a611ba --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php @@ -0,0 +1,118 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +/** + * Enforces per-profile backup retention. + * + * A profile may cap retained backups by age (retention_days) and/or by + * number of copies (retention_count). A backup is pruned when EITHER rule + * matches: it is older than retention_days OR it falls outside the newest + * retention_count copies. Deleting a record also removes its archive and + * log file, mirroring the Backup table's delete(). + */ +final class RetentionManager +{ + /** + * Prune old backups for a profile according to its retention settings. + * + * Called after a backup completes. Only 'complete' and 'warning' records + * are considered — pending/running/failed records are never pruned here. + * + * @param object $db Database driver + * @param object $profile Profile row (needs id, retention_days, retention_count) + * + * @return int Number of backup records deleted + */ + public static function prune(object $db, object $profile): int + { + $days = (int) ($profile->retention_days ?? 0); + $count = (int) ($profile->retention_count ?? 0); + + // No retention configured — nothing to do. + if ($days <= 0 && $count <= 0) { + return 0; + } + + // Newest first, so the index is the copy's position from the top. + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'absolute_path', 'backupstart'])) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')') + ->order($db->quoteName('backupstart') . ' DESC'); + $db->setQuery($query); + $records = $db->loadObjectList() ?: []; + + if (empty($records)) { + return 0; + } + + $cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null; + $deleted = 0; + + foreach ($records as $index => $record) { + $tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs; + $overCount = $count > 0 && $index >= $count; + + // Delete-if-either: prune when age OR count rule is exceeded. + if (!$tooOld && !$overCount) { + continue; + } + + if (self::deleteRecord($db, $record)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * Delete a single backup record and its on-disk archive + log file. + * + * The DB row is removed first; the files are only unlinked if that + * succeeds, so a failed delete never orphans the record from its files. + */ + private static function deleteRecord(object $db, object $record): bool + { + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $record->id); + $db->setQuery($query); + + try { + $db->execute(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage()); + + return false; + } + + $archivePath = (string) ($record->absolute_path ?? ''); + + if ($archivePath !== '' && is_file($archivePath)) { + @unlink($archivePath); + + $logPath = BackupDirectory::logPathFromArchive($archivePath); + + if (is_file($logPath)) { + @unlink($logPath); + } + } + + return true; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 467b616..bbcf906 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -602,6 +602,13 @@ class SteppedBackupEngine if ($uploadFailed) { NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent); } + + // Enforce per-profile retention (age and/or copy count). + $pruned = RetentionManager::prune($db, $profile); + + if ($pruned > 0) { + $session->log('Retention: pruned ' . $pruned . ' old backup(s)'); + } } } catch (\Throwable $e) { error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage()); diff --git a/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php index d1fe0c9..3083ac0 100644 --- a/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php @@ -37,7 +37,7 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { - ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database'); $user = Factory::getApplication()->getIdentity(); diff --git a/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php index 3b45d0f..ba43de3 100644 --- a/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php @@ -99,7 +99,7 @@ class HtmlView extends BaseHtmlView { $user = Factory::getApplication()->getIdentity(); - ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database'); if ($user->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) { ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true); diff --git a/source/packages/com_mokosuitebackup/src/View/Dashboard/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Dashboard/HtmlView.php index f5c9b88..454a6ae 100644 --- a/source/packages/com_mokosuitebackup/src/View/Dashboard/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Dashboard/HtmlView.php @@ -52,7 +52,7 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { - ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_TITLE'), 'archive'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_TITLE'), 'archive'); ToolbarHelper::preferences('com_mokosuitebackup'); } } diff --git a/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php index 315c942..0db045b 100644 --- a/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Profile/HtmlView.php @@ -44,7 +44,7 @@ class HtmlView extends BaseHtmlView ? $user->authorise('core.create', 'com_mokosuitebackup') : $user->authorise('core.edit', 'com_mokosuitebackup'); - ToolbarHelper::title(Text::_($title), 'cog'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_($title), 'cog'); if ($canSave) { ToolbarHelper::apply('profile.apply'); diff --git a/source/packages/com_mokosuitebackup/src/View/Profiles/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Profiles/HtmlView.php index ce3563e..34f350c 100644 --- a/source/packages/com_mokosuitebackup/src/View/Profiles/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Profiles/HtmlView.php @@ -49,7 +49,7 @@ class HtmlView extends BaseHtmlView { $user = Factory::getApplication()->getIdentity(); - ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_PROFILES_TITLE'), 'cog'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_PROFILES_TITLE'), 'cog'); if ($user->authorise('core.create', 'com_mokosuitebackup')) { ToolbarHelper::addNew('profile.add'); diff --git a/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php index 089e7e4..308afbe 100644 --- a/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Snapshots/HtmlView.php @@ -38,7 +38,7 @@ class HtmlView extends BaseHtmlView { $user = Factory::getApplication()->getIdentity(); - ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera'); if ($user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { ToolbarHelper::custom('snapshots.create', 'plus', '', 'COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE', false); diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index 5417461..53fe54c 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -684,19 +684,37 @@ $listDirn = $this->escape($this->state->get('list.direction')); var PURGE_TOKEN = ; var purgeCountTimer = null; - // Intercept Purge toolbar button to show the modal + // Reset modal state and show it. + function openPurgeModal() { + document.getElementById('mb-purge-date').value = ''; + document.getElementById('mb-purge-count-wrapper').style.display = 'none'; + document.getElementById('mb-purge-none-wrapper').style.display = 'none'; + document.getElementById('mb-purge-submit').disabled = true; + bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show(); + } + + // Primary: wrap Joomla.submitbutton so the Purge toolbar button opens the + // modal instead of submitting the no-op backups.purgeModal task. This is + // resilient to how the Atum toolbar renders the button markup. + if (window.Joomla && typeof Joomla.submitbutton === 'function') { + var origSubmitbutton = Joomla.submitbutton; + Joomla.submitbutton = function(task) { + if (task === 'backups.purgeModal') { + openPurgeModal(); + return false; + } + return origSubmitbutton.apply(this, arguments); + }; + } + document.addEventListener('DOMContentLoaded', function() { + // Fallback: if the button still exposes an inline onclick, bind directly. var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash'); if (purgeBtn) { purgeBtn.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); - // Reset modal state - document.getElementById('mb-purge-date').value = ''; - document.getElementById('mb-purge-count-wrapper').style.display = 'none'; - document.getElementById('mb-purge-none-wrapper').style.display = 'none'; - document.getElementById('mb-purge-submit').disabled = true; - bootstrap.Modal.getOrCreateInstance(document.getElementById('mb-purge-modal')).show(); + openPurgeModal(); return false; }, true); } diff --git a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php index 779a212..85ccad8 100644 --- a/source/packages/com_mokosuitebackup/tmpl/profile/edit.php +++ b/source/packages/com_mokosuitebackup/tmpl/profile/edit.php @@ -42,6 +42,7 @@ $token = Session::getFormToken();
form->renderFieldset('archive'); ?> + form->renderFieldset('retention'); ?>
diff --git a/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml b/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml index 09c6624..439582f 100644 --- a/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml +++ b/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml @@ -7,15 +7,15 @@ * @license GNU General Public License version 3 or later; see LICENSE --> - mod_mokosuitebackup_cpanel - 02.52.25 + Module - MokoSuiteBackup - cPanel + 02.55.00 2026-06-23 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION + Displays backup status, Backup Now buttons, and quick links on the admin dashboard. Joomla\Module\MokoSuiteBackupCpanel diff --git a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml index 1c25140..0028425 100644 --- a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Action Log - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-04 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_ACTIONLOG_MOKOJOOMBACKUP_DESCRIPTION + Logs MokoSuiteBackup actions (backup, restore, profile changes) to User Action Logs. Joomla\Plugin\Actionlog\MokoSuiteBackup diff --git a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml index 1612b4a..d3dd271 100644 --- a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Console - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-04 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_CONSOLE_MOKOJOOMBACKUP_DESCRIPTION + CLI commands for MokoSuiteBackup: run, list, profiles, restore, cleanup. Joomla\Plugin\Console\MokoSuiteBackup diff --git a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml index 967a767..d9b0bbd 100644 --- a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Content - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-04 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_CONTENT_MOKOJOOMBACKUP_DESCRIPTION + Automatically triggers a backup before extension installs or updates. Joomla\Plugin\Content\MokoSuiteBackup diff --git a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml index af8383d..c7f0749 100644 --- a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml @@ -1,14 +1,14 @@ Quick Icon - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_QUICKICON_MOKOJOOMBACKUP_DESCRIPTION + Shows backup status on the administrator dashboard. Joomla\Plugin\Quickicon\MokoSuiteBackup diff --git a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml index 23218c4..0a0470b 100644 --- a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> System - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_SYSTEM_MOKOJOOMBACKUP_DESCRIPTION + Automatic cleanup of expired backup archives and scheduled backup triggers. Joomla\Plugin\System\MokoSuiteBackup diff --git a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml index 452d63d..c5448a1 100644 --- a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Task - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_TASK_MOKOJOOMBACKUP_DESCRIPTION + Scheduled task plugin for MokoSuiteBackup. Run backup profiles on a schedule via Joomla's Scheduled Tasks. Joomla\Plugin\Task\MokoSuiteBackup diff --git a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml index c854bc0..319fd0b 100644 --- a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Web Services - MokoSuiteBackup - 02.52.25 + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PLG_WEBSERVICES_MOKOJOOMBACKUP_DESCRIPTION + REST API for remote backup management. Joomla\Plugin\WebServices\MokoSuiteBackup diff --git a/source/pkg_mokosuitebackup.xml b/source/pkg_mokosuitebackup.xml index e89b07e..9f782dc 100644 --- a/source/pkg_mokosuitebackup.xml +++ b/source/pkg_mokosuitebackup.xml @@ -8,14 +8,14 @@ Package - MokoSuiteBackup mokosuitebackup - 02.52.25 + 02.55.00 2026-06-02 Moko Consulting hello@mokoconsulting.tech https://mokoconsulting.tech Copyright (C) 2026 Moko Consulting. All rights reserved. GPL-3.0-or-later - PKG_MOKOJOOMBACKUP_DESCRIPTION + Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API. script.php diff --git a/source/script.php b/source/script.php index b3d47fc..ebf16d0 100644 --- a/source/script.php +++ b/source/script.php @@ -12,6 +12,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Installer\InstallerAdapter; use Joomla\CMS\Language\Text; +use Joomla\CMS\Log\Log; use Joomla\CMS\Router\Route; class Pkg_MokoSuiteBackupInstallerScript @@ -73,22 +74,25 @@ class Pkg_MokoSuiteBackupInstallerScript /* Save download key before Joomla re-registers the update site */ if ($type === 'update') { - $this->preflight_saveKey(); + $this->backupDownloadKey(); } return true; } /** - * Called before install/update to preserve the download key. - * - * Joomla re-registers update sites from the manifest on every update, - * which can reset the extra_query (download key). We save it here - * and restore it in postflight. + * The download key cached during preflight so it survives an update. */ private ?string $savedDownloadKey = null; - public function preflight_saveKey(): void + /** + * Cache the existing download key from the update sites table before update runs. + * + * Joomla re-registers update sites from the manifest on every update, which + * can reset the extra_query (download key). We save it here and restore it + * in postflight. + */ + private function backupDownloadKey(): void { try { $db = Factory::getDbo(); @@ -108,19 +112,16 @@ class Pkg_MokoSuiteBackupInstallerScript ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuitebackup')) ->where($db->quoteName('e.type') . ' = ' . $db->quote('package')) ->setLimit(1); - $db->setQuery($query); - $key = $db->loadResult(); - if (!empty($key)) { - $this->savedDownloadKey = $key; + $db->setQuery($query); + $extraQuery = (string) $db->loadResult(); + + if (!empty($extraQuery)) { + parse_str($extraQuery, $output); + $this->savedDownloadKey = $output['dlid'] ?? $extraQuery; } } catch (\Exception $e) { - error_log('MokoSuiteBackup: Could not save download key: ' . $e->getMessage()); - Factory::getApplication()->enqueueMessage( - 'MokoSuiteBackup could not preserve your download/license key before the update. ' - . 'Please verify your license key is still configured in System → Update Sites after this update completes.', - 'warning' - ); + Log::add('MokoSuiteBackup: Could not backup download key: ' . $e->getMessage(), Log::WARNING, 'jerror'); } } @@ -138,8 +139,8 @@ class Pkg_MokoSuiteBackupInstallerScript return; } - /* Restore download key if it was saved before update */ - if ($this->savedDownloadKey !== null) { + /* Restore the download key preserved before the update re-registered the site */ + if ($type === 'update') { $this->restoreDownloadKey(); } @@ -168,14 +169,17 @@ class Pkg_MokoSuiteBackupInstallerScript /* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */ $this->syncMenuIcons(); - /* Warn if no license key configured */ - $this->warnMissingLicenseKey(); - /* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */ $this->migrateDefaultBackupDir(); - /* Remind user to review backup profile settings */ + /* Install completion notice (install and update) */ + $this->installSuccessful(); + if ($type === 'install') { + /* Fresh install never carries a download key — prompt for one */ + $this->warnMissingLicenseKey(); + + /* Remind user to review backup profile settings */ $profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles'); Factory::getApplication()->enqueueMessage( @@ -640,48 +644,57 @@ class Pkg_MokoSuiteBackupInstallerScript ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokosuitebackup')) ->where($db->quoteName('e.type') . ' = ' . $db->quote('package')) ->setLimit(1); + $db->setQuery($query); $updateSiteId = (int) $db->loadResult(); - if ($updateSiteId > 0) { + if ($updateSiteId > 0 && !empty($this->savedDownloadKey)) { $query = $db->getQuery(true) ->update($db->quoteName('#__update_sites')) - ->set($db->quoteName('extra_query') . ' = ' . $db->quote($this->savedDownloadKey)) + ->set($db->quoteName('extra_query') . ' = ' . $db->quote('dlid=' . $this->savedDownloadKey)) ->where($db->quoteName('update_site_id') . ' = ' . $updateSiteId); + $db->setQuery($query); $db->execute(); } } catch (\Exception $e) { - error_log('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage()); + Log::add('MokoSuiteBackup: Could not restore download key: ' . $e->getMessage(), Log::WARNING, 'jerror'); + Factory::getApplication()->enqueueMessage( - 'MokoSuiteBackup: Your download/license key could not be preserved during the update. ' - . 'Please re-enter it in the Update Sites configuration to continue receiving updates.', + '

MokoSuiteBackup

' + . '

Your download/license key could not be preserved during the update.

' + . '

Please re-enter it in the Update Sites manager to continue receiving updates.

', 'warning' ); } } + /** + * Show post-install license key prompt. + */ private function warnMissingLicenseKey(): void { - try - { + try { + Factory::getApplication()->enqueueMessage( + '

MokoSuiteBackup License Key Required

' + . '

A download/license key (DLID) is required to receive updates.

' + . '

Enter your key in the Update Sites manager ' + . 'or contact Moko Consulting Support to obtain one.

', + 'warning' + ); + } catch (\Exception $e) {} + } + + /** + * Show install successful prompt. + */ + private function installSuccessful(): void + { + try { Factory::getApplication()->enqueueMessage( '

MokoSuiteBackup installed successfully!

', 'info' ); - - // Show post-install license key prompt - Factory::getApplication()->enqueueMessage( - 'Moko Consulting License Key Required
' - . 'A download key (DLID) is required to receive updates. ' - . 'Enter your key in the Update Sites manager ' - . 'or contact Moko Consulting Support to obtain one.', - 'warning' - ); - } - catch (\Exception $e) - { - error_log('MokoSuiteBackup: License key prompt failed: ' . $e->getMessage()); - } + } catch (\Exception $e) {} } }