diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 5865324..8337c71 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -7,7 +7,7 @@ # INGROUP: mokocli.Release # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli # 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 # # +=======================================================================+ @@ -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 @@ -75,6 +79,7 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 1 + submodules: recursive - name: Setup mokocli tools env: @@ -163,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 @@ -173,6 +182,7 @@ jobs: with: token: ${{ secrets.MOKOGITEA_TOKEN }} fetch-depth: 0 + submodules: recursive - name: Configure git for bot pushes run: | 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/deploy-manual.yml b/.mokogitea/workflows/deploy-manual.yml deleted file mode 100644 index 1af323c..0000000 --- a/.mokogitea/workflows/deploy-manual.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# -# 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 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index dfd4b5e..11958bd 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.45.07 +# VERSION: 01.00.00 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" 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 2d8435f..4667a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,158 +1,55 @@ # Changelog +All notable changes to this project are documented in this file. The format is +based on [Keep a Changelog](https://keepachangelog.com/); versions use +zero-padded `MAJOR.MINOR.PATCH`. + ## [Unreleased] -### 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 +## [02.55.00] --- 2026-07-04 + +### Removed +- Legacy single-remote storage: the per-profile `remote_storage` column and all + FTP/SFTP/S3/Google Drive credential columns. Remote destinations are now sourced + exclusively from the `#__mokosuitebackup_remotes` table. +- Orphaned `SftpPath` form field and the `ajax.browseSftpDir` endpoint (leftovers + of the removed single-SFTP path picker). ### 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" +- Package installer script adopts the standard licensing and install-completion + pattern: the download key is preserved across updates (backed up in preflight, + restored in postflight), an "installed successfully" notice shows on install and + update, and the license-key prompt shows on fresh install only. +- Akeeba import no longer copies legacy remote settings onto the profile; re-add + remote destinations on the profile's Remote tab after importing. ### Fixed -- 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 +- Schema migration `02.52.25.sql` uses plain `DROP COLUMN` (portable to both + Oracle MySQL 8.x and MariaDB) instead of the MariaDB-only `DROP COLUMN IF EXISTS`. -## [01.43.00] --- 2026-06-24 - - -## [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 +## [02.54.00] --- 2026-07-04 ### 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 +- Per-profile backup retention is now enforced. After each backup, completed + backups older than `retention_days` or beyond the newest `retention_count` + copies are pruned, along with their archive and log files (`0` = unlimited for + either rule). The retention fields are now shown on the profile editor's + Archive tab. + +### Fixed +- "Purge Old Backups" toolbar button now opens the purge modal under the Joomla 6 + Atum toolbar (it was bound to markup the toolbar no longer renders). + +### Changed +- All Joomla package manifests hardcode their titles and descriptions (no language + strings), and `` follows the `Type - Name` convention (e.g. + `Component - MokoSuiteBackup`, `Module - MokoSuiteBackup - cPanel`). + +## [02.53.00] --- 2026-07-04 + +### Added +- `COM_MOKOJOOMBACKUP_SHORT` short-name language constant. + +### Changed +- Admin views and the administrator sidebar menu use the short "Backup" name + (e.g. "Backup: Dashboard", "Backup: Records"). diff --git a/SECURITY.md b/SECURITY.md index ebe1999..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: 01.45.07 +VERSION: 02.55.00 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/source/packages/MokoSuiteClient b/source/packages/MokoSuiteClient index 67e9cc6..9df6bea 160000 --- a/source/packages/MokoSuiteClient +++ b/source/packages/MokoSuiteClient @@ -1 +1 @@ -Subproject commit 67e9cc6b38dc25371ceab6b51b643883a42bf98c +Subproject commit 9df6bea4b7480b2e443898ad84a279070ba4a7f6 diff --git a/source/packages/com_mokosuitebackup/access.xml b/source/packages/com_mokosuitebackup/access.xml index 37c2f9d..6f3220e 100644 --- a/source/packages/com_mokosuitebackup/access.xml +++ b/source/packages/com_mokosuitebackup/access.xml @@ -15,5 +15,6 @@ + diff --git a/source/packages/com_mokosuitebackup/forms/filter_backups.xml b/source/packages/com_mokosuitebackup/forms/filter_backups.xml index a44abd5..fa78e99 100644 --- a/source/packages/com_mokosuitebackup/forms/filter_backups.xml +++ b/source/packages/com_mokosuitebackup/forms/filter_backups.xml @@ -15,6 +15,7 @@ > + diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 81a46de..d7fa692 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -206,25 +206,6 @@
- - - - - - - JYES - - - - - - - - - - - - - -
@@ -408,157 +314,4 @@ />
-
- - - - - - - - - - - - - -
- -
- - - - -
- -
- - - - - - -
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 8547103..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" @@ -207,6 +208,7 @@ COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)" ; Status labels COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete" +COM_MOKOJOOMBACKUP_STATUS_WARNING="Warning" COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running" COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed" COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending" @@ -249,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" @@ -450,6 +452,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_BROWSE="Browse Archives" 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 COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots" @@ -500,6 +504,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_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) COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations" COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination" 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 ef2e72b..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." @@ -116,3 +117,27 @@ 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_SUCCESS="%d backup(s) purged successfully." 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" + +; Delete feedback +COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted." +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 753238d..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 - 01.45.07 + 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 984ff45..a2c23fd 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -11,32 +11,6 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', - `remote_storage` VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT 'none, ftp, google_drive, s3', - `ftp_host` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 21, - `ftp_username` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_password` VARCHAR(255) NOT NULL DEFAULT '', - `ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups', - `ftp_passive` TINYINT(1) NOT NULL DEFAULT 1, - `ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0, - `sftp_host` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22, - `sftp_username` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key', - `sftp_password` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_key_data` MEDIUMTEXT, - `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '', - `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups', - `gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '', - `gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '', - `gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '', - `gdrive_folder_id` VARCHAR(255) NOT NULL DEFAULT '', - `s3_endpoint` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'S3 endpoint URL (blank = AWS default)', - `s3_region` VARCHAR(50) NOT NULL DEFAULT 'us-east-1', - `s3_access_key` VARCHAR(255) NOT NULL DEFAULT '', - `s3_secret_key` VARCHAR(255) NOT NULL DEFAULT '', - `s3_bucket` VARCHAR(255) NOT NULL DEFAULT '', - `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone', @@ -49,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)', @@ -65,7 +39,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` ( `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1, `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', `backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files', `archivename` VARCHAR(512) NOT NULL DEFAULT '', diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.45.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.45.00.sql new file mode 100644 index 0000000..2865b45 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.45.00.sql @@ -0,0 +1 @@ +/* 01.45.00 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.16.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.16.sql new file mode 100644 index 0000000..cd9e5d7 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.16.sql @@ -0,0 +1 @@ +/* 02.52.16 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.17.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.17.sql new file mode 100644 index 0000000..bd657ea --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.17.sql @@ -0,0 +1 @@ +/* 02.52.17 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.18.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.18.sql new file mode 100644 index 0000000..27c00ce --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.18.sql @@ -0,0 +1 @@ +/* 02.52.18 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.20.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.20.sql new file mode 100644 index 0000000..4812b35 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.20.sql @@ -0,0 +1 @@ +/* 02.52.20 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.21.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.21.sql new file mode 100644 index 0000000..5727cbf --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.21.sql @@ -0,0 +1 @@ +/* 02.52.21 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.22.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.22.sql new file mode 100644 index 0000000..dd661bd --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.22.sql @@ -0,0 +1 @@ +/* 02.52.22 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.23.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.23.sql new file mode 100644 index 0000000..0af7fa5 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.23.sql @@ -0,0 +1 @@ +/* 02.52.23 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.24.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.24.sql new file mode 100644 index 0000000..5fbc0be --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.24.sql @@ -0,0 +1 @@ +/* 02.52.24 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql new file mode 100644 index 0000000..550a037 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.52.25.sql @@ -0,0 +1,31 @@ +-- Remove legacy single-remote storage columns (superseded by #__mokosuitebackup_remotes). +-- Plain DROP COLUMN (no IF EXISTS): all columns are created by install.mysql.sql and +-- earlier updates, so they always exist here. `DROP COLUMN IF EXISTS` is a MariaDB-only +-- extension and errors on Oracle MySQL 8.x, which Joomla also supports. +ALTER TABLE `#__mokosuitebackup_profiles` + DROP COLUMN `remote_storage`, + DROP COLUMN `ftp_host`, + DROP COLUMN `ftp_port`, + DROP COLUMN `ftp_username`, + DROP COLUMN `ftp_password`, + DROP COLUMN `ftp_path`, + DROP COLUMN `ftp_passive`, + DROP COLUMN `ftp_ssl`, + DROP COLUMN `sftp_host`, + DROP COLUMN `sftp_port`, + DROP COLUMN `sftp_username`, + DROP COLUMN `sftp_auth_type`, + DROP COLUMN `sftp_password`, + DROP COLUMN `sftp_key_data`, + DROP COLUMN `sftp_passphrase`, + DROP COLUMN `sftp_path`, + DROP COLUMN `gdrive_client_id`, + DROP COLUMN `gdrive_client_secret`, + DROP COLUMN `gdrive_refresh_token`, + DROP COLUMN `gdrive_folder_id`, + DROP COLUMN `s3_endpoint`, + DROP COLUMN `s3_region`, + DROP COLUMN `s3_access_key`, + DROP COLUMN `s3_secret_key`, + DROP COLUMN `s3_bucket`, + DROP COLUMN `s3_path`; 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/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 159af3b..c3d396f 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -84,6 +84,67 @@ class AjaxController extends BaseController $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. * POST: task=ajax.browseDir&path=/some/path @@ -451,7 +512,7 @@ class AjaxController extends BaseController 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']); return; @@ -747,7 +808,7 @@ class AjaxController extends BaseController ->select('COUNT(*)') ->from($db->quoteName('#__mokosuitebackup_records')) ->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); $count = (int) $db->loadResult(); } catch (\Exception $e) { @@ -1204,184 +1265,6 @@ class AjaxController extends BaseController return $config; } - /** - * Browse directories on a remote SFTP server for the path picker. - * POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path - */ - public function browseSftpDir(): void - { - if (!Session::checkToken('get') && !Session::checkToken('post')) { - $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); - - return; - } - - if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { - $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); - - return; - } - - $profileId = $this->input->getInt('profile_id', 0); - - if (!$profileId) { - $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); - - return; - } - - /* Load the profile to get SFTP credentials */ - try { - $db = Factory::getDbo(); - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_profiles')) - ->where($db->quoteName('id') . ' = ' . $profileId); - $db->setQuery($query); - $profile = $db->loadObject(); - } catch (\Exception $e) { - $this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500); - - return; - } - - if (!$profile) { - $this->sendJson(['error' => true, 'message' => 'Profile not found'], 404); - - return; - } - - $host = $profile->sftp_host ?? ''; - $port = (int) ($profile->sftp_port ?? 22); - $username = $profile->sftp_username ?? ''; - $keyData = $profile->sftp_key_data ?? ''; - $password = $profile->sftp_password ?? ''; - - if (empty($host) || empty($username)) { - $this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']); - - return; - } - - if (empty($keyData) && empty($password)) { - $this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']); - - return; - } - - $requestPath = $this->input->getString('path', '/'); - - /* Sanitize: must start with / and not contain shell meta-characters */ - $requestPath = '/' . ltrim($requestPath, '/'); - - if (preg_match('/[;&|`$<>]/', $requestPath)) { - $this->sendJson(['error' => true, 'message' => 'Invalid path characters']); - - return; - } - - $keyFile = null; - - try { - /* Write temp key if using key auth (same pattern as SftpUploader) */ - if (!empty($keyData)) { - $keyContent = base64_decode($keyData, true); - - if ($keyContent === false) { - $keyContent = $keyData; - } - - $keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key'; - - if (file_put_contents($keyFile, $keyContent) === false) { - throw new \RuntimeException('Cannot write temporary SSH key file'); - } - - chmod($keyFile, 0600); - } - - /* Build SSH command to list directories */ - $escapedPath = escapeshellarg($requestPath); - $remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"'; - - $parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10']; - - if ($port !== 22) { - $parts[] = '-p'; - $parts[] = (string) $port; - } - - if ($keyFile !== null) { - $parts[] = '-i'; - $parts[] = escapeshellarg($keyFile); - } - - $parts[] = escapeshellarg($username . '@' . $host); - $parts[] = escapeshellarg($remoteCmd); - - $cmd = implode(' ', $parts); - - $output = []; - $exitCode = 0; - exec($cmd . ' 2>&1', $output, $exitCode); - - /* exitCode 1 from grep means no matches (empty dir), which is OK */ - if ($exitCode !== 0 && $exitCode !== 1) { - throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output)); - } - - /* Parse output: each line is a directory name ending with / */ - $dirs = []; - - foreach ($output as $line) { - $line = trim($line); - - if ($line === '' || $line === './' || $line === '../') { - continue; - } - - $dirName = rtrim($line, '/'); - - if ($dirName === '' || $dirName === '.' || $dirName === '..') { - continue; - } - - $fullPath = rtrim($requestPath, '/') . '/' . $dirName; - - $dirs[] = [ - 'name' => $dirName, - 'path' => $fullPath, - ]; - } - - usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); - - /* Parent path */ - $parent = null; - - if ($requestPath !== '/') { - $parent = \dirname($requestPath); - - if ($parent === '') { - $parent = '/'; - } - } - - $this->sendJson([ - 'error' => false, - 'current' => $requestPath, - 'parent' => $parent, - 'dirs' => $dirs, - ]); - } catch (\Throwable $e) { - $this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]); - } finally { - if ($keyFile !== null && is_file($keyFile)) { - unlink($keyFile); - } - } - } - /** * Send a JSON response and close the application. */ diff --git a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php index 1f09d49..59a725b 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php @@ -199,7 +199,7 @@ class BackupsController extends AdminController ->select($db->quoteName('id')) ->from($db->quoteName('#__mokosuitebackup_records')) ->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); $ids = $db->loadColumn(); @@ -235,6 +235,76 @@ class BackupsController extends AdminController $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. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php index 7020926..79dca60 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php +++ b/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php @@ -228,24 +228,9 @@ class AkeebaImporter 'exclude_dirs' => implode("\n", $filters['exclude_dirs']), 'exclude_files' => implode("\n", $filters['exclude_files']), 'exclude_tables' => implode("\n", $filters['exclude_tables']), - 'remote_storage' => $this->mapRemoteStorage($config), - 'ftp_host' => $config['engine.postproc.ftp.host'] ?? '', - 'ftp_port' => (int) ($config['engine.postproc.ftp.port'] ?? 21), - 'ftp_username' => $config['engine.postproc.ftp.user'] ?? '', - 'ftp_password' => $config['engine.postproc.ftp.pass'] ?? '', - 'ftp_path' => $config['engine.postproc.ftp.initial_directory'] ?? '/backups', - 'ftp_passive' => (int) ($config['engine.postproc.ftp.passive_mode'] ?? 1), - 'ftp_ssl' => (int) ($config['engine.postproc.ftp.ftps'] ?? 0), - 'gdrive_client_id' => $config['engine.postproc.googledrive.client_id'] ?? '', - 'gdrive_client_secret' => $config['engine.postproc.googledrive.client_secret'] ?? '', - 'gdrive_refresh_token' => $config['engine.postproc.googledrive.refresh_token'] ?? '', - 'gdrive_folder_id' => $config['engine.postproc.googledrive.directory'] ?? '', - 's3_endpoint' => $config['engine.postproc.s3.custom_endpoint'] ?? '', - 's3_region' => $config['engine.postproc.s3.region'] ?? 'us-east-1', - 's3_access_key' => $config['engine.postproc.s3.access_key'] ?? ($config['engine.postproc.s3.accesskey'] ?? ''), - 's3_secret_key' => $config['engine.postproc.s3.secret_key'] ?? ($config['engine.postproc.s3.secretkey'] ?? ''), - 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', - 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', + // Remote storage is no longer stored on the profile — it lives in + // #__mokosuitebackup_remotes. Akeeba remote settings are not imported; + // re-add remote destinations on the profile's Remote tab after import. 'remote_keep_local' => 1, 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'published' => 1, diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index 5eb994c..d9105d3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -321,48 +321,6 @@ class BackupEngine @unlink($archivePath); $this->log('Local copy removed (remote_keep_local = off)'); } - } else { - /* Backward-compat: fall back to legacy single-remote column */ - $remoteStorage = $profile->remote_storage ?? 'none'; - - if ($remoteStorage !== 'none') { - try { - $this->log('Starting remote upload (' . $remoteStorage . ')...'); - $uploader = $this->createUploader($remoteStorage, $profile); - $uploadResult = $uploader->upload($archivePath, $archiveName); - - if ($uploadResult['success']) { - $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; - $this->log('Remote upload complete: ' . $uploadResult['message']); - - if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { - $restoreBasename = basename($restoreScriptPath); - $this->log('Uploading standalone ' . $restoreBasename . '...'); - $restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename); - - if ($restoreUpload['success']) { - $this->log('Standalone ' . $restoreBasename . ' uploaded'); - } else { - $this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']); - } - } - - // Delete local copy if configured - if (empty($profile->remote_keep_local) && is_file($archivePath)) { - @unlink($archivePath); - $this->log('Local copy removed (remote_keep_local = off)'); - } - } else { - $uploadFailed = true; - $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); - $this->log('Local backup is preserved.'); - } - } catch (\Throwable $e) { - $uploadFailed = true; - $this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); - $this->log('Local backup is preserved.'); - } - } } // Write log file alongside the archive @@ -375,7 +333,7 @@ class BackupEngine // Final record update (includes fields needed by NotificationSender) $update = (object) [ 'id' => $recordId, - 'status' => 'complete', + 'status' => $uploadFailed ? 'warning' : 'complete', 'description' => $description, 'backup_type' => $profile->backup_type, 'archivename' => $archiveName, @@ -403,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); @@ -519,23 +488,7 @@ class BackupEngine } /** - * Create the appropriate remote uploader based on the storage type. - * Legacy method — used by backward-compat fallback when remotes table - * does not exist. - */ - private function createUploader(string $type, object $profile): RemoteUploaderInterface - { - return match ($type) { - 'ftp' => new FtpUploader($profile), - 'sftp' => new SftpUploader($profile), - 'google_drive' => new GoogleDriveUploader($profile), - 's3' => new S3Uploader($profile), - default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), - }; - } - - /** - * Create a remote uploader from JSON params (multi-remote destinations). + * Create a remote uploader from JSON params. * * Builds a fake profile-like object from the params array so the existing * uploader constructors work without modification. @@ -547,7 +500,16 @@ class BackupEngine */ 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) { 'ftp' => new FtpUploader($fake), @@ -560,31 +522,18 @@ class BackupEngine /** * Load enabled remote destinations for a profile from the remotes table. - * - * Returns an empty array when the table does not exist (pre-migration) - * so the caller can fall back to the legacy single-remote column. - * - * @param object $db Database driver - * @param int $profileId Profile ID - * - * @return object[] Array of remote destination rows */ private function loadRemoteDestinations(object $db, int $profileId): array { - try { - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_remotes')) - ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) - ->where($db->quoteName('enabled') . ' = 1') - ->order($db->quoteName('ordering') . ' ASC'); - $db->setQuery($query); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); - return $db->loadObjectList() ?: []; - } catch (\Throwable $e) { - // Table does not exist yet (pre-migration) — fall back to legacy - return []; - } + return $db->loadObjectList() ?: []; } /** @@ -597,7 +546,7 @@ class BackupEngine ->select($db->quoteName('manifest')) ->from($db->quoteName('#__mokosuitebackup_records')) ->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('backup_type') . ' = ' . $db->quote('full')) ->order($db->quoteName('backupstart') . ' DESC'); diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index 78c4b2a..eb6ea0a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -346,6 +346,9 @@ define('MOKOJOOMBACKUP_RESTORE', 1); define('RESTORE_DIR', __DIR__); 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(); 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. // 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. -$securityFile = RESTORE_DIR . '/.mokorestore-security.php'; +$securityFile = RESTORE_DIR . '/mokorestore-security.php'; $securityCode = $_SESSION['security_code'] ?? ''; if (empty($securityCode)) { $securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)); $_SESSION['security_code'] = $securityCode; $_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 = "\n" . "MokoRestore Security Verification\n" . "==================================\n" . "Code: " . $securityCode . "\n" . "Enter this code in the MokoRestore browser interface to proceed.\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; - 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) { $_SESSION['security_verified'] = true; + error_log('MokoRestore: Security code VERIFIED'); - // Delete the security file if (is_file($securityFile)) { @unlink($securityFile); + error_log('MokoRestore: Security file deleted'); } echo json_encode(['success' => true, 'message' => 'Security verified']); } 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; @@ -414,7 +431,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { } 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; } @@ -424,9 +441,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { @ignore_user_abort(true); try { + error_log('MokoRestore: Action dispatched — ' . $_POST['action']); $result = handleAction($_POST['action'], $_POST); + error_log('MokoRestore: Action ' . $_POST['action'] . ' completed — ' . ($result['success'] ? 'OK' : 'FAIL: ' . ($result['message'] ?? ''))); echo json_encode($result); } catch (Throwable $e) { + error_log('MokoRestore: Action ' . $_POST['action'] . ' EXCEPTION — ' . $e->getMessage()); echo json_encode(['success' => false, 'message' => $e->getMessage()]); } @@ -551,10 +571,14 @@ function actionPreflight(): 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)) { 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(); if ($zip->open(BACKUP_FILE) !== true) { @@ -591,6 +615,8 @@ function actionExtract(array $data): array $count = $zip->numFiles; $zip->close(); + error_log('MokoRestore: Extract — ' . $count . ' files extracted to ' . RESTORE_DIR); + // Pre-fill from configuration.php.bak (sanitized backup) or // configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values. $existingConfig = []; @@ -719,6 +745,8 @@ function actionDatabase(array $data): array $user = $data['db_user'] ?? ''; $pass = $data['db_pass'] ?? ''; + error_log('MokoRestore: Database import — host=' . $host . ', db=' . $name . ', user=' . $user); + if (empty($name) || empty($user)) { throw new RuntimeException('Database name and user are required'); } @@ -726,9 +754,12 @@ function actionDatabase(array $data): array $sqlFile = RESTORE_DIR . '/database.sql'; 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]; } + error_log('MokoRestore: Database import — SQL file size=' . number_format(filesize($sqlFile) / 1048576, 2) . ' MB'); + $pdo = new PDO( "mysql:host={$host};dbname={$name};charset=utf8mb4", $user, @@ -835,6 +866,14 @@ function actionDatabase(array $data): array $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 [ 'success' => ($statements > 0 || $errors === 0), 'message' => $msg, @@ -847,6 +886,7 @@ function actionDatabase(array $data): array function actionConfig(array $data): array { + error_log('MokoRestore: Config rebuild started'); $host = $data['db_host'] ?? 'localhost'; $dbName = $data['db_name'] ?? ''; $dbUser = $data['db_user'] ?? ''; @@ -867,6 +907,7 @@ function actionConfig(array $data): array // debug, cache, SEF, editor, etc.). Fall back to existing config // for legacy/unsanitized backups, or build from scratch if neither exists. $basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null); + error_log('MokoRestore: Config — base template: ' . ($basePath ?? 'none (building from scratch)')); if ($basePath !== null) { $config = file_get_contents($basePath); @@ -919,9 +960,12 @@ function actionConfig(array $data): array } 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']; } + error_log('MokoRestore: Config — written to ' . $configPath . ' (' . filesize($configPath) . ' bytes)'); + // Remove .bak after successful rebuild if (is_file($bakPath)) { @unlink($bakPath); @@ -1175,6 +1219,8 @@ function actionResetAdmin(array $data): array $userId = (int) ($data['admin_id'] ?? 0); $password = $data['new_password'] ?? ''; + error_log('MokoRestore: Admin password reset — user_id=' . $userId); + if ($userId < 1 || strlen($password) < 8) { 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'); } + error_log('MokoRestore: Admin password reset — success'); return ['success' => true, 'message' => 'Admin password updated successfully']; } @@ -1197,6 +1244,7 @@ function actionPostRestore(array $data): array $prefix = getValidatedPrefix($data); $tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; $results = []; + error_log('MokoRestore: Post-restore — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks)); foreach ($tasks as $task) { try { @@ -1319,6 +1367,7 @@ function actionProvision(array $data): array $prefix = getValidatedPrefix($data); $tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; $results = []; + error_log('MokoRestore: Provisioning — ' . count($tasks) . ' task(s): ' . implode(', ', $tasks)); foreach ($tasks as $task) { try { @@ -1395,16 +1444,24 @@ function actionProvision(array $data): array function actionCleanup(): array { + error_log('MokoRestore: Cleanup started'); $removed = []; - foreach (['database.sql', 'site-backup.zip'] as $file) { + foreach (['database.sql', 'site-backup.zip', 'mokorestore-security.php'] as $file) { $path = RESTORE_DIR . '/' . $file; - if (is_file($path) && @unlink($path)) { - $removed[] = $file; + if (is_file($path)) { + if (@unlink($path)) { + $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 [ 'success' => true, 'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed)) @@ -1570,14 +1627,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N

Security Verification

-

To prevent unauthorized access, enter the security code from the file .mokorestore-security.php in your site root.

+

To prevent unauthorized access, enter the security code from the file mokorestore-security.php in your site root.

🔒 How to find the code
  1. Connect to your server via FTP, SSH, or file manager
  2. -
  3. Open .mokorestore-security.php in the site root directory
  4. +
  5. Open mokorestore-security.php in the site root directory
  6. Copy the 8-character code and enter it below
diff --git a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php index ac62cd3..6ccbd90 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php @@ -77,7 +77,7 @@ class PreflightCheck $this->checkDiskSpace($profile, $db); $this->checkRunningBackup($profile, $db); $this->checkExcludedTables($profile, $db); - $this->checkRemoteCredentials($profile); + $this->checkRemoteCredentials($profile, $db); return $this->result(); } @@ -102,12 +102,8 @@ class PreflightCheck } } - // curl is only needed for remote upload and ntfy notifications - $needsCurl = ($profile->remote_storage ?? 'none') !== 'none' - || !empty($profile->ntfy_topic); - - if ($needsCurl && !extension_loaded('curl')) { - $this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work'; + if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) { + $this->warnings[] = 'ext-curl is not loaded — ntfy notifications will not work'; } } @@ -165,7 +161,7 @@ class PreflightCheck ->select($db->quoteName('total_size')) ->from($db->quoteName('#__mokosuitebackup_records')) ->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') ->order($db->quoteName('backupstart') . ' DESC'); $db->setQuery($query, 0, 1); @@ -194,22 +190,58 @@ class PreflightCheck } } + private const STALE_TIMEOUT_MINUTES = 30; + /** * 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 { $query = $db->getQuery(true) - ->select('COUNT(*)') + ->select($db->quoteName(['id', 'backupstart', 'absolute_path'])) ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('status') . ' = ' . $db->quote('running')); $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 - . ' — wait for it to finish or delete the stale record'; + . ' — wait for it to finish or use Cancel Stalled from the Backup Records toolbar'; } } @@ -244,65 +276,76 @@ class PreflightCheck } /** - * Check that remote storage credentials are minimally configured. + * Check that remote destination credentials are minimally configured. * Does not test the actual connection (too slow for preflight). */ - private function checkRemoteCredentials(object $profile): void + private function checkRemoteCredentials(object $profile, object $db): void { - $remote = $profile->remote_storage ?? 'none'; + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('enabled') . ' = 1'); + $db->setQuery($query); + $remotes = $db->loadObjectList(); - if ($remote === 'none') { + if (empty($remotes)) { return; } - switch ($remote) { - case 'ftp': - if (empty($profile->ftp_host)) { - $this->warnings[] = 'FTP host is not configured — remote upload will fail'; - } + foreach ($remotes as $remote) { + $params = json_decode($remote->params, true) ?: []; + $label = $remote->title ?: ('Remote #' . $remote->id); - if (empty($profile->ftp_username)) { - $this->warnings[] = 'FTP username is not configured — remote upload will fail'; - } + switch ($remote->type) { + case 'ftp': + if (empty($params['host'])) { + $this->warnings[] = $label . ': FTP host is not configured — upload will fail'; + } - break; + if (empty($params['username'])) { + $this->warnings[] = $label . ': FTP username is not configured — upload will fail'; + } - case 's3': - if (empty($profile->s3_bucket)) { - $this->warnings[] = 'S3 bucket is not configured — remote upload will fail'; - } + break; - if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) { - $this->warnings[] = 'S3 credentials are not configured — remote upload will fail'; - } + case 's3': + if (empty($params['bucket'])) { + $this->warnings[] = $label . ': S3 bucket is not configured — upload will fail'; + } - break; + if (empty($params['access_key']) || empty($params['secret_key'])) { + $this->warnings[] = $label . ': S3 credentials are not configured — upload will fail'; + } - case 'sftp': - if (empty($profile->sftp_host)) { - $this->warnings[] = 'SFTP host is not configured — remote upload will fail'; - } + break; - if (empty($profile->sftp_username)) { - $this->warnings[] = 'SFTP username is not configured — remote upload will fail'; - } + case 'sftp': + if (empty($params['host'])) { + $this->warnings[] = $label . ': SFTP host is not configured — upload will fail'; + } - if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) { - $this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail'; - } + if (empty($params['username'])) { + $this->warnings[] = $label . ': SFTP username is not configured — upload will fail'; + } - break; + if (empty($params['key_data']) && empty($params['password'])) { + $this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail'; + } - case 'google_drive': - if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) { - $this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail'; - } + break; - if (empty($profile->gdrive_refresh_token)) { - $this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail'; - } + case 'google_drive': + if (empty($params['client_id']) || empty($params['client_secret'])) { + $this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail'; + } - break; + if (empty($params['refresh_token'])) { + $this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail'; + } + + break; + } } } diff --git a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php index 55202d0..88ad222 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php @@ -67,7 +67,7 @@ class RestoreEngine 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 . ')']; } 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 0901d09..bbcf906 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -69,7 +69,6 @@ class SteppedBackupEngine $session->excludeFiles = BackupDirectory::parseNewlineList($profile->exclude_files ?? ''); $session->excludeTables = BackupDirectory::parseNewlineList($profile->exclude_tables ?? ''); $session->backupDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; - $session->remoteStorage = $profile->remote_storage ?? 'none'; $session->includeMokoRestore = $profile->include_mokorestore ?? '0'; $session->restoreScriptName = $profile->restore_script_name ?? 'restore.php'; $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); @@ -153,15 +152,8 @@ class SteppedBackupEngine $totalSteps += 1; // finalize step - // Determine upload step count: one step per remote destination, - // or one step for legacy single-remote, or zero if no remotes. $remoteCount = count($session->remoteDestinations); - - if ($remoteCount > 0) { - $totalSteps += $remoteCount; - } elseif ($session->remoteStorage !== 'none') { - $totalSteps += 1; - } + $totalSteps += $remoteCount; $session->totalSteps = $totalSteps; $session->currentStep = 1; @@ -394,8 +386,14 @@ class SteppedBackupEngine $restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName); $restoreDir = dirname($session->archivePath); $session->restoreScriptPath = $restoreDir . '/' . $restoreScriptName; - MokoRestore::generateStandalone($session->restoreScriptPath); - $session->log('Standalone ' . $restoreScriptName . ' generated'); + + try { + MokoRestore::generateStandalone($session->restoreScriptPath); + $session->log('Standalone ' . $restoreScriptName . ' generated'); + } catch (\Throwable $e) { + $session->log('MokoRestore error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + $session->log('Stack trace: ' . $e->getTraceAsString()); + } } // Update record @@ -415,11 +413,7 @@ class SteppedBackupEngine $session->currentStep++; - // Determine next phase: multi-remote, legacy single-remote, or complete - $hasMultiRemote = !empty($session->remoteDestinations); - $hasLegacyRemote = $session->remoteStorage !== 'none'; - - if ($hasMultiRemote || $hasLegacyRemote) { + if (!empty($session->remoteDestinations)) { $session->phase = 'upload'; } else { $session->phase = 'complete'; @@ -434,11 +428,7 @@ class SteppedBackupEngine } /** - * Upload phase: send archive to remote storage. - * - * When multi-remote destinations are configured, each call uploads to - * one destination (one step per remote). When only the legacy - * single-remote column is set, uploads in a single step. + * Upload phase: send archive to one remote destination per call. */ private function stepUpload(SteppedSession $session): void { @@ -446,133 +436,65 @@ class SteppedBackupEngine $remoteFilename = ''; $uploadFailed = false; - if (!empty($session->remoteDestinations)) { - // ── Multi-remote path ────────────────────────────────── - $index = $session->remoteIndex; + $index = $session->remoteIndex; - if ($index >= count($session->remoteDestinations)) { - // All remotes processed — move to complete - $session->phase = 'complete'; - $session->statusMessage = 'All remote uploads finished'; - $this->completeRecord($session); + if ($index >= count($session->remoteDestinations)) { + $session->phase = 'complete'; + $session->statusMessage = 'All remote uploads finished'; + $this->completeRecord($session); - return; - } + return; + } - $remote = (object) $session->remoteDestinations[$index]; + $remote = (object) $session->remoteDestinations[$index]; - try { - $title = $remote->title ?? ('Remote #' . ($index + 1)); - $type = $remote->type ?? 'unknown'; - $params = json_decode($remote->params ?? '{}', true) ?: []; + try { + $title = $remote->title ?? ('Remote #' . ($index + 1)); + $type = $remote->type ?? 'unknown'; + $params = json_decode($remote->params ?? '{}', true) ?: []; - $session->log('Uploading to: ' . $title . ' (' . $type . ')...'); - $uploader = $this->createUploaderFromParams($type, $params); - $result = $uploader->upload($session->archivePath, $session->archiveName); + $session->log('Uploading to: ' . $title . ' (' . $type . ')...'); + $uploader = $this->createUploaderFromParams($type, $params); + $result = $uploader->upload($session->archivePath, $session->archiveName); - if ($result['success']) { - $remoteFilename = $result['remote_path'] ?? $session->archiveName; - $session->log(' Upload complete: ' . $result['message']); + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $session->archiveName; + $session->log(' Upload complete: ' . $result['message']); - if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { - $uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath)); - } - } else { - $uploadFailed = true; - $session->log(' WARNING: Upload failed: ' . $result['message']); + if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { + $uploader->upload($session->restoreScriptPath, basename($session->restoreScriptPath)); } - } catch (\Throwable $e) { + } else { $uploadFailed = true; - $session->log(' WARNING: Upload exception: ' . $e->getMessage()); + $session->log(' WARNING: Upload failed: ' . $result['message']); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $session->log(' WARNING: Upload exception: ' . $e->getMessage()); + } + + $session->remoteIndex++; + $session->currentStep++; + + $remaining = count($session->remoteDestinations) - $session->remoteIndex; + $session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : ''); + + if ($session->remoteIndex >= count($session->remoteDestinations)) { + if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) { + @unlink($session->archivePath); + $session->log('Local copy removed (remote_keep_local = off)'); } - $session->remoteIndex++; - $session->currentStep++; - - $remaining = count($session->remoteDestinations) - $session->remoteIndex; - $session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : ''); - - if ($session->remoteIndex >= count($session->remoteDestinations)) { - // All remotes done — delete local if configured and no failures - if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) { - @unlink($session->archivePath); - $session->log('Local copy removed (remote_keep_local = off)'); - } - - // Update record with remote filename - $update = (object) [ - 'id' => $session->recordId, - 'remote_filename' => $remoteFilename, - 'filesexist' => is_file($session->archivePath) ? 1 : 0, - ]; - $db->updateObject('#__mokosuitebackup_records', $update, 'id'); - - $session->phase = 'complete'; - $session->statusMessage = $uploadFailed - ? 'Backup complete (some remote uploads failed — local archive preserved)' - : 'Backup complete'; - $this->completeRecord($session, $uploadFailed); - } - } else { - // ── Legacy single-remote fallback ────────────────────── - try { - // Reload profile for remote settings - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_profiles')) - ->where($db->quoteName('id') . ' = ' . $session->profileId); - $db->setQuery($query); - $profile = $db->loadObject(); - - $uploader = match ($session->remoteStorage) { - 'ftp' => new FtpUploader($profile), - 'sftp' => new SftpUploader($profile), - 'google_drive' => new GoogleDriveUploader($profile), - 's3' => new S3Uploader($profile), - default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), - }; - - $session->log('Starting remote upload (' . $session->remoteStorage . ')...'); - $result = $uploader->upload($session->archivePath, $session->archiveName); - - if ($result['success']) { - $remoteFilename = $result['remote_path'] ?? $session->archiveName; - $session->log('Remote upload complete: ' . $result['message']); - - if (!empty($session->restoreScriptPath) && is_file($session->restoreScriptPath)) { - $restoreBasename = basename($session->restoreScriptPath); - $session->log('Uploading standalone ' . $restoreBasename . '...'); - $uploader->upload($session->restoreScriptPath, $restoreBasename); - } - - if (!$session->remoteKeepLocal && is_file($session->archivePath)) { - @unlink($session->archivePath); - $session->log('Local copy removed'); - } - } else { - $uploadFailed = true; - $session->log('WARNING: Remote upload failed: ' . $result['message']); - $session->log('Local backup is preserved.'); - } - } catch (\Throwable $e) { - $uploadFailed = true; - $session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); - $session->log('Local backup is preserved.'); - } - - // Update record with remote filename $update = (object) [ 'id' => $session->recordId, 'remote_filename' => $remoteFilename, 'filesexist' => is_file($session->archivePath) ? 1 : 0, ]; - $db->updateObject('#__mokosuitebackup_records', $update, 'id'); - $session->currentStep++; $session->phase = 'complete'; $session->statusMessage = $uploadFailed - ? 'Backup complete (remote upload failed — local archive preserved)' + ? 'Backup complete (some remote uploads failed — local archive preserved)' : 'Backup complete'; $this->completeRecord($session, $uploadFailed); } @@ -641,7 +563,7 @@ class SteppedBackupEngine $update = (object) [ 'id' => $session->recordId, - 'status' => 'complete', + 'status' => $uploadFailed ? 'warning' : 'complete', 'backupend' => date('Y-m-d H:i:s'), 'total_size' => $totalSize, 'checksum' => $checksum, @@ -680,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()); @@ -853,21 +782,15 @@ class SteppedBackupEngine */ private function loadRemoteDestinations(object $db, int $profileId): array { - try { - $query = $db->getQuery(true) - ->select('*') - ->from($db->quoteName('#__mokosuitebackup_remotes')) - ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) - ->where($db->quoteName('enabled') . ' = 1') - ->order($db->quoteName('ordering') . ' ASC'); - $db->setQuery($query); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); - // Use loadAssocList so the data survives JSON serialization in SteppedSession - return $db->loadAssocList() ?: []; - } catch (\Throwable $e) { - // Table does not exist yet (pre-migration) — fall back to legacy - return []; - } + return $db->loadAssocList() ?: []; } /** @@ -883,7 +806,16 @@ class SteppedBackupEngine */ 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) { 'ftp' => new FtpUploader($fake), diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php index cf1b9ef..25668b1 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php @@ -64,7 +64,7 @@ class SteppedRestoreEngine 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 . ')']; } diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php index c153c52..3a103b3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php @@ -50,7 +50,6 @@ class SteppedSession public array $excludeDirs = []; public array $excludeFiles = []; public array $excludeTables = []; - public string $remoteStorage = 'none'; public string $includeMokoRestore = '0'; public string $restoreScriptName = 'restore.php'; public string $restoreScriptPath = ''; diff --git a/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php b/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php deleted file mode 100644 index b501999..0000000 --- a/source/packages/com_mokosuitebackup/src/Field/SftpPathField.php +++ /dev/null @@ -1,253 +0,0 @@ - - * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. - * @license GNU General Public License version 3 or later; see LICENSE - * - * SFTP remote path field with Browse Remote button and modal directory browser. - */ - -namespace Joomla\Component\MokoSuiteBackup\Administrator\Field; - -defined('_JEXEC') or die; - -use Joomla\CMS\Form\FormField; - -class SftpPathField extends FormField -{ - protected $type = 'SftpPath'; - - protected function getInput(): string - { - $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8'); - $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); - $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); - - return << - - -
- - -HTML; - } -} diff --git a/source/packages/com_mokosuitebackup/src/Helper/BackupStatusHelper.php b/source/packages/com_mokosuitebackup/src/Helper/BackupStatusHelper.php index 7de26bc..a711f97 100644 --- a/source/packages/com_mokosuitebackup/src/Helper/BackupStatusHelper.php +++ b/source/packages/com_mokosuitebackup/src/Helper/BackupStatusHelper.php @@ -70,7 +70,7 @@ class BackupStatusHelper ]) ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->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'); if ($profileId !== null) { @@ -148,7 +148,7 @@ class BackupStatusHelper $query = $db->getQuery(true) ->select($db->quoteName('status')) ->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') ->setLimit(50); @@ -156,7 +156,7 @@ class BackupStatusHelper $streak = 0; foreach ($statuses as $s) { - if ($s === 'complete') { + if ($s === 'complete' || $s === 'warning') { $streak++; } else { break; diff --git a/source/packages/com_mokosuitebackup/src/Model/DashboardModel.php b/source/packages/com_mokosuitebackup/src/Model/DashboardModel.php index ae85d6e..8ad63df 100644 --- a/source/packages/com_mokosuitebackup/src/Model/DashboardModel.php +++ b/source/packages/com_mokosuitebackup/src/Model/DashboardModel.php @@ -30,7 +30,7 @@ class DashboardModel extends BaseDatabaseModel ->select('r.*, p.title AS profile_title') ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->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'); $db->setQuery($query, 0, 1); @@ -75,7 +75,7 @@ class DashboardModel extends BaseDatabaseModel ->select('COUNT(*) AS total_count') ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size') ->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); $stats = $db->loadObject(); @@ -274,7 +274,7 @@ class DashboardModel extends BaseDatabaseModel ->select('COALESCE(SUM(r.total_size), 0) AS total_size') ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->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')) ->order('total_size DESC'); $db->setQuery($query); diff --git a/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php index cdc2c7f..3083ac0 100644 --- a/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php +++ b/source/packages/com_mokosuitebackup/src/View/Backup/HtmlView.php @@ -37,11 +37,11 @@ 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(); - if ($this->item->status === 'complete' + if (\in_array($this->item->status, ['complete', 'warning'], true) && !empty($this->item->filesexist) && $user->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup') ) { diff --git a/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Backups/HtmlView.php index 4816383..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); @@ -113,6 +113,10 @@ class HtmlView extends BaseHtmlView 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')) { ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); } 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/backup/default.php b/source/packages/com_mokosuitebackup/tmpl/backup/default.php index bcaf51f..49dfd58 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backup/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backup/default.php @@ -30,6 +30,7 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false) item->status) { 'complete' => 'badge bg-success', + 'warning' => 'badge bg-warning text-dark', 'running' => 'badge bg-info', 'fail' => 'badge bg-danger', default => 'badge bg-secondary', diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index f5e664a..53fe54c 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -92,6 +92,7 @@ $listDirn = $this->escape($this->state->get('list.direction')); status) { 'complete' => 'badge bg-success', + 'warning' => 'badge bg-warning text-dark', 'running' => 'badge bg-info', 'fail' => 'badge bg-danger', default => 'badge bg-secondary', @@ -683,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 1f6e636..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'); ?>
@@ -65,7 +66,6 @@ $token = Session::getFormToken();
-
@@ -97,20 +97,13 @@ $token = Session::getFormToken();

-
+ +
+ +
- -
- - form->renderFieldset('remote'); ?> - form->renderFieldset('ftp'); ?> - form->renderFieldset('google_drive'); ?> - form->renderFieldset('s3'); ?> -
+ form->renderFieldset('remote'); ?>
@@ -280,9 +273,8 @@ document.addEventListener('DOMContentLoaded', function() { const tbody = document.getElementById('remoteDestBody'); const emptyMsg = document.getElementById('remoteDestEmpty'); const loadingTr = document.getElementById('remoteDestLoading'); - const legacy = document.getElementById('legacyRemoteFields'); - const legacyNote = document.getElementById('legacyRemoteNote'); - const modal = new bootstrap.Modal(document.getElementById('remoteModal')); + const modalEl = document.getElementById('remoteModal'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); // Type badge colours const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'}; @@ -336,14 +328,10 @@ document.addEventListener('DOMContentLoaded', function() { if (!remotesData.length) { emptyMsg.style.display = ''; - legacy.style.display = ''; - legacyNote.style.display = 'none'; return; } emptyMsg.style.display = 'none'; - legacy.style.display = 'none'; - legacyNote.style.display = 'block'; remotesData.forEach(function(item) { const tr = document.createElement('tr'); @@ -506,8 +494,8 @@ document.addEventListener('DOMContentLoaded', function() { const fields = configFields[item.type] || []; fields.forEach(function(f) { const el = document.getElementById('remoteCfg_' + prefix + f); - if (el && item.config && item.config[f] !== undefined) { - el.value = item.config[f]; + if (el && item.params && item.params[f] !== undefined) { + el.value = item.params[f]; } }); } diff --git a/source/packages/mod_mokosuitebackup_cpanel/language/en-GB/mod_mokosuitebackup_cpanel.ini b/source/packages/mod_mokosuitebackup_cpanel/language/en-GB/mod_mokosuitebackup_cpanel.ini index 50cb35a..6ca28a1 100644 --- a/source/packages/mod_mokosuitebackup_cpanel/language/en-GB/mod_mokosuitebackup_cpanel.ini +++ b/source/packages/mod_mokosuitebackup_cpanel/language/en-GB/mod_mokosuitebackup_cpanel.ini @@ -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_STATUS_OK="Success" +MOD_MOKOSUITEBACKUP_CPANEL_STATUS_WARNING="Warning" MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed" MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet." MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables" diff --git a/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml b/source/packages/mod_mokosuitebackup_cpanel/mod_mokosuitebackup_cpanel.xml index 373331b..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 - 01.45.07 + 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/mod_mokosuitebackup_cpanel/tmpl/default.php b/source/packages/mod_mokosuitebackup_cpanel/tmpl/default.php index bad6adf..456d8fa 100644 --- a/source/packages/mod_mokosuitebackup_cpanel/tmpl/default.php +++ b/source/packages/mod_mokosuitebackup_cpanel/tmpl/default.php @@ -51,10 +51,20 @@ $moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
- - + 'bg-success', + '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'), + }; + ?> + + diff --git a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml index 65407e9..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 - 01.45.07 + 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 3b1c99e..d3dd271 100644 --- a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Console - MokoSuiteBackup - 01.45.07 + 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 7501aa7..d9b0bbd 100644 --- a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Content - MokoSuiteBackup - 01.45.07 + 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 77c32fe..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 - 01.45.07 + 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 c5f6d9a..0a0470b 100644 --- a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> System - MokoSuiteBackup - 01.45.07 + 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 4cea5d2..c5448a1 100644 --- a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml @@ -7,14 +7,14 @@ --> Task - MokoSuiteBackup - 01.45.07 + 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 cd110d2..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 - 01.45.07 + 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 2231196..9f782dc 100644 --- a/source/pkg_mokosuitebackup.xml +++ b/source/pkg_mokosuitebackup.xml @@ -8,14 +8,14 @@ Package - MokoSuiteBackup mokosuitebackup - 01.45.07 + 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_MOKOJOOMBCKUP_DESCRIPTION + Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API. script.php @@ -29,6 +29,7 @@ plg_content_mokosuitebackup.zip plg_actionlog_mokosuitebackup.zip mod_mokosuitebackup_cpanel.zip + MokoSuiteClient.zip @@ -36,7 +37,7 @@ - https://git.mokoconsulting.tech/api/packages/MokoConsulting/generic/MokoSuiteBackup/latest/updates.xml + https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/updates.xml true diff --git a/source/script.php b/source/script.php index 67668df..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,66 +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 - { - $db = Factory::getDbo(); - $db->setQuery( - $db->getQuery(true) - ->select([$db->quoteName('update_site_id'), $db->quoteName('extra_query')]) - ->from($db->quoteName('#__update_sites')) - ->where('(' . $db->quoteName('name') . ' LIKE ' . $db->quote('%MokoSuiteBackup%') . ' OR ' . $db->quoteName('location') . ' LIKE ' . $db->quote('%MokoSuiteBackup%') . ')') - ->setLimit(1) - ); - $site = $db->loadObject(); - - if ($site) - { - $eq = (string) ($site->extra_query ?? ''); - if (!empty($eq) && strpos($eq, 'dlid=') !== false) { parse_str($eq, $p); if (!empty($p['dlid'])) { return; } } - $editUrl = 'index.php?option=com_installer&task=updatesite.edit&update_site_id=' . (int) $site->update_site_id; - } - else - { - $editUrl = 'index.php?option=com_installer&view=updatesites'; - } - + try { Factory::getApplication()->enqueueMessage( - 'Moko Consulting License Key Required — ' - . 'No download key is configured. Updates will not be available until a valid license key is entered. ' - . 'Enter License Key', + '

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) { - error_log('MokoSuiteBackup: License key check failed: ' . $e->getMessage()); + } catch (\Exception $e) {} + } + + /** + * Show install successful prompt. + */ + private function installSuccessful(): void + { + try { Factory::getApplication()->enqueueMessage( - 'MokoSuiteBackup could not verify your license key status. ' - . 'Please check System → Update Sites to ensure a valid license key is configured.', - 'warning' + '

MokoSuiteBackup installed successfully!

', + 'info' ); - } + } catch (\Exception $e) {} } }