Compare commits

..

48 Commits

Author SHA1 Message Date
gitea-actions[bot] e633d0cc0a chore(version): pre-release bump to 01.41.03-dev [skip ci] 2026-06-23 22:20:21 +00:00
Jonathan Miller ff7418721d fix: review findings — key desc, missing changelog, [HOST] domain resolution
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
- Language: "encrypted" → "base64-encoded" for SSH key description
- CHANGELOG: added 3 missing bug fix entries (fields_values scope, CSRF
  token on Run Backup, SFTP showon/required)
- [HOST] placeholder: resolve domain from Joomla live_site config when
  HTTP_HOST is unavailable (CLI), instead of falling back to system
  hostname (joomla.invalid). Applied to both PlaceholderResolver and
  FolderPickerField.
2026-06-23 17:20:05 -05:00
gitea-actions[bot] 0b2b885163 chore(version): pre-release bump to 01.41.02-dev [skip ci] 2026-06-23 22:10:35 +00:00
Jonathan Miller 6c47838b30 fix: clean up wordy field descriptions — shorter, punchier text
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 18s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 48s
Backup dir, archive name, MokoRestore, SFTP key, sanitization,
encryption descriptions all shortened. Removed redundant placeholder
lists (now handled by clickable pills and help modal).
2026-06-23 17:09:59 -05:00
gitea-actions[bot] 0f95cb6e9f chore(version): pre-release bump to 01.41.01-dev [skip ci] 2026-06-23 22:01:24 +00:00
Jonathan Miller 1da2fdb856 docs: comprehensive CHANGELOG consolidation for v01.41.00
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Consolidated all fragmented changelog entries from the session into
a single clean v01.41.00 release entry organized by feature area.
Covers: multi-remote, snapshots, SFTP, MokoRestore, sanitization,
engine improvements, admin UI, CLI/API, notifications, security.
2026-06-23 17:01:11 -05:00
gitea-actions[bot] 4bafaa519a chore: promote changelog [Unreleased] → [01.41.00] 2026-06-23 21:54:11 +00:00
gitea-actions[bot] 3c32bd93e9 chore(release): build 01.41.00 [skip ci] 2026-06-23 21:54:07 +00:00
jmiller ef17873448 Merge pull request 'feat: Multi-remote storage — multiple destinations per profile (#97)' (#139) from feat/multi-remote-storage into main 2026-06-23 21:53:51 +00:00
Jonathan Miller dae30161ae feat: multi-remote storage — multiple destinations per profile (#97)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
New #__mokosuitebackup_remotes table stores remote destinations with
JSON params per type (SFTP/S3/GDrive/FTP). Each profile can have
multiple enabled destinations — the engine uploads to all of them.

Database:
- New table with profile_id FK, type, enabled, params JSON, ordering
- Migration auto-converts existing profile remote columns to new table
- RemoteTable, RemoteModel, RemotesModel classes

Engine:
- BackupEngine: loadRemoteDestinations() + createUploaderFromParams()
  iterates all enabled remotes, falls back to legacy columns
- SteppedBackupEngine: one upload step per remote destination, persisted
  via session.remoteDestinations + remoteIndex
- Local copy only deleted when ALL uploads succeed

UI:
- Profile edit: "Remote Destinations" linked table with AJAX CRUD
- Add/edit modal with type selector showing dynamic fields
- Toggle enabled/disabled, delete with confirmation
- Legacy fields hidden when remotes configured, shown as fallback
- Secrets masked in responses, merged from DB on save

Closes #97
2026-06-23 16:53:08 -05:00
jmiller 8e70bfb723 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 21:52:37 +00:00
jmiller dcd772018e chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 21:52:36 +00:00
jmiller 26d765b74e chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 21:52:35 +00:00
jmiller 78b68d2647 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 21:52:34 +00:00
gitea-actions[bot] 50a879155d chore: promote changelog [Unreleased] → [01.40.00] 2026-06-23 19:18:42 +00:00
gitea-actions[bot] b4fb674566 chore(release): build 01.40.00 [skip ci] 2026-06-23 19:18:28 +00:00
jmiller 1b93d2ac21 Merge pull request 'feat: Complete config.xml, access.xml + ACL enforcement audit (#137)' (#138) from feat/config-acl-audit into main 2026-06-23 19:17:48 +00:00
Jonathan Miller 8e5913d706 fix: enforce correct ACL permissions across all controllers (#137)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 11s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 33s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 54s
13 ACL fixes across 5 files:
- BackupsController: purge() uses backup.purge (was core.delete)
- SnapshotsController: delete() uses snapshot.manage (was core.delete)
- AjaxController: restoreInit/Step use backup.restore (was backup.run),
  browseArchive uses backup.browse (was core.manage),
  countPurge uses backup.purge (was core.delete),
  compareBackups uses backup.compare (was core.manage)
- API SnapshotsController: displayList/download use snapshot.manage
  (was core.manage)
- HtmlView: verify gated by core.manage, compare by backup.compare,
  purge separated from delete with backup.purge

Closes #137
2026-06-23 14:16:54 -05:00
Jonathan Miller 1f7def05c1 feat: complete config.xml and access.xml (#137)
config.xml:
- Defaults fieldset: archive format, MokoRestore mode, sanitization
  defaults (passwords, emails, sessions), log retention days
- Global ntfy fieldset: server, topic, token (fallback for profiles)

access.xml:
- mokosuitebackup.backup.purge — bulk delete old backups
- mokosuitebackup.backup.compare — compare two backups
- mokosuitebackup.backup.browse — browse archive file listings

30+ new language strings for all fields and ACL actions.

Partial #137 (ACL enforcement audit in separate commit)
2026-06-23 14:04:12 -05:00
gitea-actions[bot] 95317fb707 chore: promote changelog [Unreleased] → [01.39.01] 2026-06-23 18:37:04 +00:00
gitea-actions[bot] cb5ff2843d chore(release): build 01.39.01 [skip ci] 2026-06-23 18:36:54 +00:00
jmiller 4e6369094b Merge pull request 'fix: Resolve merge conflict markers in script.php' (#136) from fix/script-merge-conflicts into main 2026-06-23 18:32:45 +00:00
Jonathan Miller 0fbcc861d9 fix: resolve merge conflict markers in script.php
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 10s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 44s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 12s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3m49s
12 conflict markers from a stale stash pop were accidentally
committed to main in PR #135. Resolved all by keeping the
"Updated upstream" blocks which include both error_log() and
user-facing enqueueMessage() calls.
2026-06-23 13:32:03 -05:00
jmiller 8cea58d1f6 chore: remove security-audit.yml -- handled by MokoGitea 2026-06-23 18:27:10 +00:00
jmiller 84511b08d2 Merge pull request 'feat: Purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)' (#135) from feat/final-batch into main 2026-06-23 18:06:23 +00:00
Jonathan Miller 899a33bc58 feat: purge, CPanel module, 7z format, SFTP browser (#119, #105, #122, #98)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 10s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m50s
#119: Manual purge — toolbar button opens modal with date picker,
AJAX count preview, confirmation before bulk delete.

#105: CPanel admin dashboard module (mod_mokosuitebackup_cpanel) —
backup status, quick action buttons per profile, next scheduled,
stats, and quick links. Registered in package manifest.

#122: 7z archive format via system 7za/7z CLI binary with optional
password encryption. New SevenZipArchiver engine class.

#98: SFTP remote file browser — custom SftpPathField with "Browse
Remote" button, modal directory listing via SSH ls, click to navigate,
double-click to select.

Also: CHANGELOG updated, wiki Home updated, #121 verified (encryption
field already visible in Archive Settings tab).

Closes #119, closes #105, closes #122, closes #98, closes #121
2026-06-23 13:05:42 -05:00
jmiller 7970597fb8 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 17:51:32 +00:00
jmiller 13f1c1db5e chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 17:51:30 +00:00
jmiller 7ea30aa146 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 17:51:30 +00:00
jmiller d96f3e7760 chore: sync deploy-manual.yml from Template-Generic [skip ci] 2026-06-23 17:51:29 +00:00
jmiller 10b31fea84 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 17:51:28 +00:00
jmiller 997924a107 Merge pull request 'fix: MokoRestore review findings + README rewrite' (#134) from fix/mokorestore-review-findings into main 2026-06-23 17:49:49 +00:00
gitea-actions[bot] 9319abec41 chore(version): pre-release bump to 01.39.01-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 8s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m25s
2026-06-23 17:49:32 +00:00
Jonathan Miller 7e404b0246 fix: MokoRestore review findings + update README
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 13s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: Build & Release / Promote to RC (pull_request) Failing after 10s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 50s
Review fixes:
- data-only mode uses REPLACE INTO (was INSERT INTO, broke on dupes)
- temporary password is random 16-char hex (was hardcoded "changeme")

README rewritten with all features: snapshots, SFTP, MokoRestore
wizard, sanitization, dashboard, CLI, API.
2026-06-23 12:49:17 -05:00
jmiller 6638577cf5 Merge pull request 'feat: MokoRestore post-restore resets + per-table conflict resolution' (#133) from feat/mokorestore-enhancements into main 2026-06-23 17:37:49 +00:00
Jonathan Miller 114995242d chore: merge main, resolve conflicts, remove stale files
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 58s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 5s
2026-06-23 12:37:29 -05:00
jmiller 3d6c0974fa chore: remove deprecated .mokogitea/workflows/composer-publish.yml [skip ci] 2026-06-23 17:36:30 +00:00
jmiller 8aefc1d702 chore: remove deprecated .mokogitea/workflows/deploy-manual.yml [skip ci] 2026-06-23 17:36:27 +00:00
Jonathan Miller da52a9d2f9 Merge remote-tracking branch 'origin/main' into feat/mokorestore-enhancements
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 17s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 51s
2026-06-23 12:33:59 -05:00
Jonathan Miller 0dc0eb1bef feat: MokoRestore post-restore resets + per-table conflict resolution (#131, #132)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Failing after 13s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 53s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m0s
#131: Post-restore actions step in MokoRestore wizard:
- Reset all passwords to temporary "changeme" with requireReset flag
- Reset article hit counters to zero
- Clear content versions (#__history)
- Clear sessions (#__session)
- Clear cache tables and filesystem cache
- Auto-detect sanitized password hashes and prompt for reset

#132: Per-table conflict resolution during database import:
- New "Tables" step shows all tables from database.sql
- Per-table dropdown: Replace / Skip / Merge / Data Only
- Preset buttons: All Replace, All Skip, Everything except users
- Skip mode skips all statements for that table
- Merge mode uses INSERT IGNORE instead of INSERT INTO
- Data Only skips DROP/CREATE, inserts data only

Wizard now has 9 steps: Pre-check → Extract → Tables → Database →
Config → Admin → Post-Restore → Provisioning → Complete

Closes #131, closes #132
2026-06-23 12:33:18 -05:00
jmiller 1def73df19 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 17:11:33 +00:00
jmiller 48f132ecf9 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 17:11:32 +00:00
jmiller c17349277d chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 17:11:31 +00:00
jmiller 5a6ad02b53 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 17:11:29 +00:00
gitea-actions[bot] 29da9776cd chore: promote changelog [Unreleased] → [01.39.00] 2026-06-23 17:08:24 +00:00
gitea-actions[bot] 09bac755a9 chore(release): build 01.39.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 17:08:16 +00:00
jmiller f830dc2ddf Merge pull request 'feat: Data sanitization — passwords, emails, sessions (#129)' (#130) from feat/sanitize-user-passwords into main 2026-06-23 17:06:47 +00:00
Jonathan Miller 5698c074da feat: data sanitization — passwords, emails, sessions (#129)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 54s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
New "Data Sanitization" fieldset on profile form with four options:
- Sanitize User Passwords: replaces all bcrypt hashes with invalid sentinel
- Preserve Super Admin: keeps Super Users group passwords intact
- Sanitize User Emails: replaces with user123@sanitized.example.com
- Clear Session Data: excludes #__session table data (default: on)

DatabaseDumper sanitizes rows inline during dump — both in-memory
and file-streaming paths. Super admin detection uses group_id=8
from #__user_usergroup_map with static caching.

Use cases: sharing backups, creating demo/staging sites, GDPR compliance.

Partial #129 (Part 2 — restore script password reset — tracked separately)
2026-06-23 12:06:19 -05:00
50 changed files with 4221 additions and 371 deletions
-76
View File
@@ -1,76 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.38.05
# VERSION: 01.41.03
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-82
View File
@@ -1,82 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
+118 -6
View File
@@ -1,14 +1,126 @@
# Changelog
## [Unreleased]
## [01.38.05] --- 2026-06-23
## [01.41.00] 2026-06-23
## [01.38.05] --- 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
## [01.38.04] --- 2026-06-23
### 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
## [01.38.04] --- 2026-06-23
### 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
## [01.38.03] --- 2026-06-23
### 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
## [01.38.03] --- 2026-06-23
### 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
- Run Backup button on profile list and edit views with backup count badges
- "Do not navigate away" warning in backup/restore progress modals
- Clickable placeholder pills for backup directory and archive name fields
- Comprehensive help modal with absolute/relative/placeholder path documentation
- Placeholder resolution display with EXAMPLE prefix
- All placeholders UPPERCASE: [HOST], [SITE_NAME], [DATE], [DATETIME], etc.
### Added — CLI & API
- `mokosuitebackup:restore` with --files-only, --db-only, --password options
- `mokosuitebackup:snapshot` with create, restore, list, delete actions
- REST API for snapshots: create, list, restore, delete, download
- Profile credentials masked in API responses
### Added — Notifications & Logging
- Email/ntfy notifications for site restore, snapshot create/restore
- Joomla Action Logs for restore, snapshot, and snapshot restore events
- Global ntfy server/topic/token settings (fallback for profiles)
### Added — Security & Configuration
- Webcron secret field with CSPRNG generator + strength meter
- IP whitelist field with current IP detection + one-click "Add my IP"
- 10 ACL permissions with full enforcement audit across all controllers
- Config defaults: archive format, MokoRestore mode, sanitization settings
- Path traversal protection on all archive extraction (ZIP, tar.gz, JPA)
### Fixed
- CLI RestoreCommand passed wrong arguments (filepath instead of record ID)
- JPA path traversal: reject `../` in archive entry paths
- S3Uploader OOM: streaming upload instead of file_get_contents
- DatabaseDumper OOM: streaming to file instead of in-memory string
- AkeebaImporter: removed unserialize() (PHP object injection risk)
- BackupTable: delete DB row before file (prevents data loss)
- RestoreEngine: staging path sanitized with preg_replace
- API profiles: sensitive fields masked with `***`
- Webcron: missing return after sendJsonResponse on auth failure
- loadFormData(): cast array to object (PHP 8.x TypeError fix)
- MokoRestore data-only mode: uses REPLACE INTO for existing rows
- Plaintext archive deleted on encryption failure
- TarGzArchiver: intermediate .tar cleaned in finally block
- Install script: single-line comments converted to block comments
- Orphaned root-level webservices plugin files removed
- include_mokorestore column: TINYINT changed to VARCHAR(20)
- Snapshot fields_values: scoped dump and restore to com_content.article (previously destroyed values for contacts, users, etc.)
- Run Backup button: accept CSRF token from GET (fixes "token did not match" on profile edit)
- SFTP fields: moved into remote fieldset for showon visibility; removed required attr that blocked non-SFTP saves
- Script.php merge conflict markers resolved
## [01.24.00] — 2026-06-02
### Added
- Initial release: full-site backup and restore for Joomla 6
- Database, files, and configuration backup
- ZIP and tar.gz archive formats with AES-256 encryption
- Differential backups based on file manifests
- FTP/FTPS, S3, Google Drive remote storage
- MokoRestore standalone restore wizard
- CLI backup and restore commands
- REST API for remote management
- Scheduled tasks via com_scheduler
- Email and ntfy push notifications
- Per-profile retention, exclusions, and notifications
- Akeeba Backup migration tool
- Admin dashboard with system health checks
+64 -34
View File
@@ -1,50 +1,80 @@
# MokoSuiteBackup
<!-- VERSION: 01.38.05 -->
Full-site backup and restore for Joomla — database, files, and configuration.
## Overview
MokoSuiteBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management.
| Field | Value |
|---|---|
| **Package** | `pkg_mokosuitebackup` |
| **Type** | Joomla Package (8 sub-extensions) |
| **Joomla** | 6.x+ |
| **PHP** | 8.1+ |
| **License** | GPL-3.0-or-later |
## Features
- Full site backup (database + files + configuration)
- Database-only backup mode
- Files-only backup mode
- Multiple backup profiles with independent configurations
- File and directory exclusion filters
- Table exclusion filters for database backups
- Step-based backup engine (avoids PHP timeout on large sites)
- CLI script for cron/scheduled backups
- REST API (Joomla Web Services) for remote management
- Backup record management (list, download, delete)
- Automatic old backup cleanup (configurable retention)
- Admin dashboard with backup history and storage usage
### Backup
- Full site, database-only, files-only, and differential backup modes
- Pre-flight validation — checks directory, disk space, extensions, credentials before starting
- Auto-verify archive integrity after creation
- Stepped AJAX engine prevents timeout on shared hosting
- AES-256 ZIP encryption with configurable password
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
### Content Snapshots
- Lightweight JSON snapshots of articles, categories, and modules
- Includes tags, custom fields, workflow associations
- Restore modes: Replace (clean slate) or Merge (upsert)
- Selective article restore — browse and pick individual items
- Automatic retention (max count + max age)
- Scheduled snapshot task via com_scheduler
### Remote Storage
- SFTP with SSH key file authentication (key stored base64-encoded in database)
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
- Google Drive with OAuth2 and resumable uploads
- Graceful degradation — local backup preserved if upload fails
### MokoRestore Standalone Wizard
- 9-step restore wizard that works without Joomla installed
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
- Post-restore actions: reset passwords, hits, versions, sessions, cache
- Auto-detect sanitized passwords and prompt for reset
- Standalone mode: restore.php scans directory for ZIP files
- Wrapped mode: restore.php bundled inside backup ZIP
- Security gate with filesystem verification
### Notifications
- Email on success/failure per profile
- ntfy push notifications
- Notifications for restore and snapshot operations
### Admin Dashboard
- Last backup status, next scheduled, total count, storage used
- Snapshot widget with latest info and type badges
- 30-day backup trend chart
- Per-profile storage breakdown
- System health checks
### CLI
- `mokosuitebackup:run --profile=1` — run backup
- `mokosuitebackup:restore 1 --files-only --db-only --password=xxx`
- `mokosuitebackup:snapshot create|restore|list|delete`
### REST API
- Backup: start, list, download, delete, profiles
- Snapshots: create, list, restore, delete, download
- Profile credentials masked in API responses
## Installation
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
2. Joomla Administrator > Extensions > Install
3. System plugin enabled automatically on install
3. Components > MokoSuiteBackup > Dashboard
## Configuration
## Documentation
- **Component**: Administrator > Components > MokoSuiteBackup
- **Profiles**: Create backup profiles with different file/database filters
- **System Plugin**: Configure scheduled backup triggers and notifications
- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups
## REST API
The webservices plugin exposes endpoints compatible with the MokoBackup MCP server:
- `POST /api/index.php/v1/mokobackup/backup` — Start a backup
- `GET /api/index.php/v1/mokobackup/backups` — List backup records
- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive
- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record
- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles
See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
## License
-1
View File
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -12,5 +12,8 @@
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
</section>
</access>
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
*/
public function displayList(): static
{
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
*/
public function download(): static
{
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
@@ -39,6 +39,73 @@
</field>
</fieldset>
<fieldset name="defaults" label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS">
<field
name="default_archive_format"
type="list"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC"
default="zip"
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
<option value="7z">7z</option>
</field>
<field
name="default_mokorestore"
type="list"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC"
default="0"
>
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
</field>
<field
name="default_sanitize_passwords"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="default_sanitize_emails"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="default_sanitize_sessions"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="log_retention_days"
type="number"
label="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION"
description="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC"
default="90"
min="0"
max="365"
/>
</fieldset>
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
<field
name="webcron_secret"
@@ -172,6 +239,32 @@
</field>
</fieldset>
<fieldset name="ntfy" label="COM_MOKOJOOMBACKUP_CONFIG_NTFY">
<field
name="ntfy_server"
type="text"
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
default="https://ntfy.sh"
filter="url"
/>
<field
name="ntfy_topic"
type="text"
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC"
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC"
default=""
filter="string"
/>
<field
name="ntfy_token"
type="password"
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN"
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC"
default=""
/>
</fieldset>
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC">
<field
@@ -40,6 +40,7 @@
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
</field>
<field
name="compression_level"
@@ -101,6 +102,54 @@
/>
</fieldset>
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
<field
name="sanitize_passwords"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="preserve_super_admin"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
default="1"
class="btn-group"
showon="sanitize_passwords:1"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_emails"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_sessions"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
<field
name="id"
@@ -153,6 +202,13 @@
</fieldset>
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
<field
name="remote_legacy_note"
type="note"
label=""
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
class="alert alert-info small"
/>
<field
name="remote_storage"
type="list"
@@ -243,12 +299,13 @@
/>
<field
name="sftp_path"
type="text"
type="SftpPath"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:sftp"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
@@ -119,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
; Archive settings
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
@@ -126,19 +127,30 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)"
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups."
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="AES-256 encryption password. Leave blank for no encryption. Required to restore."
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [HOST], [DATE], [YEAR], [MONTH], [DAY], [PROFILE_NAME], [SITE_NAME], [TYPE]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Where backups are stored. Use placeholders like [HOME]/backups for portability. Click the ? icon for full documentation."
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [HOST] hostname, [DATE] Ymd, [TIME] His, [DATETIME] Ymd_His, [YEAR] [MONTH] [DAY] [HOUR] [MINUTE] [SECOND], [PROFILE_ID], [PROFILE_NAME], [SITE_NAME], [TYPE], [RANDOM]."
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template (without extension). Click the placeholder buttons below to insert tokens."
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="None: no restore script. Wrapped: bundled inside the ZIP. Standalone: separate restore.php file (ideal for remote servers)."
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
; Data Sanitization
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace password hashes with invalid values. Users must reset passwords after restore. For demos, staging, or GDPR."
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace emails with dummy values. Prevents accidental emails from cloned sites. Super admin preserved if enabled above."
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude session data. Logs out all users on restore, prevents session hijacking. Enabled by default."
; Exclusion filter fields
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
@@ -265,7 +277,7 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only. Leave blank for password auth."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
@@ -402,6 +414,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
; Component Options — Defaults
COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS="Profile Defaults"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT="Default Archive Format"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC="Archive format used when creating new profiles. Can be overridden per profile."
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE="Default MokoRestore Mode"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC="MokoRestore mode for new profiles. None, Wrapped (inside ZIP), or Standalone (separate file)."
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW="Default: Sanitize Passwords"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC="Whether new profiles should sanitize user passwords by default."
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL="Default: Sanitize Emails"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC="Whether new profiles should sanitize user emails by default."
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS="Default: Clear Sessions"
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC="Whether new profiles should clear session data by default."
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION="Log Retention (days)"
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC="Days to keep .log files alongside backup archives. Set to 0 for unlimited."
; Component Options — ntfy
COM_MOKOJOOMBACKUP_CONFIG_NTFY="Push Notifications (ntfy)"
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER="Global ntfy Server"
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC="Default ntfy server URL. Per-profile settings override this."
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC="Global ntfy Topic"
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC="Default ntfy topic for backup notifications. Per-profile settings override this."
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN="Global ntfy Token"
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC="Default access token for private ntfy topics. Per-profile settings override this."
; ACL — additional actions
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE="Purge Old Backups"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE_DESC="Allows users to bulk-delete backups older than a specific date."
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."
; Snapshot ACL
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
@@ -438,6 +482,28 @@ COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
; Purge
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
; Remote Destinations (multi-remote)
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
COM_MOKOJOOMBACKUP_REMOTE_EDIT="Edit Destination"
COM_MOKOJOOMBACKUP_REMOTE_ENABLED="Enabled"
COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED="No remote destinations configured. Use 'Add Destination' to send backups to SFTP, S3, or Google Drive."
COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE="Legacy single-remote fields below are hidden when remote destinations are configured above. Existing legacy settings continue to work as a fallback."
COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM="Are you sure you want to delete this remote destination?"
; Errors
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
@@ -103,3 +103,16 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
; Purge
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`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',
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
@@ -103,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` INT(11) UNSIGNED NOT NULL,
`title` VARCHAR(255) NOT NULL DEFAULT '',
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
`ordering` INT(11) NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_profile` (`profile_id`),
KEY `idx_enabled` (`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
`id`, `title`, `description`, `backup_type`,
@@ -1,2 +1,3 @@
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
@@ -0,0 +1,7 @@
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
@@ -0,0 +1,97 @@
-- MokoSuiteBackup 01.41.00 — Multi-remote storage destinations (#97)
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` INT(11) UNSIGNED NOT NULL,
`title` VARCHAR(255) NOT NULL DEFAULT '',
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
`ordering` INT(11) NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `idx_profile` (`profile_id`),
KEY `idx_enabled` (`profile_id`, `enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Migrate existing SFTP remote configs into new table
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
SELECT
`id`,
CONCAT(`title`, ' - SFTP'),
'sftp',
1,
JSON_OBJECT(
'host', `sftp_host`,
'port', `sftp_port`,
'username', `sftp_username`,
'auth_type', `sftp_auth_type`,
'password', `sftp_password`,
'key_data', COALESCE(`sftp_key_data`, ''),
'passphrase', `sftp_passphrase`,
'path', `sftp_path`
),
1,
NOW()
FROM `#__mokosuitebackup_profiles`
WHERE `remote_storage` = 'sftp' AND `sftp_host` != '';
-- Migrate existing S3 remote configs into new table
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
SELECT
`id`,
CONCAT(`title`, ' - S3'),
's3',
1,
JSON_OBJECT(
'endpoint', `s3_endpoint`,
'region', `s3_region`,
'access_key', `s3_access_key`,
'secret_key', `s3_secret_key`,
'bucket', `s3_bucket`,
'path', `s3_path`
),
1,
NOW()
FROM `#__mokosuitebackup_profiles`
WHERE `remote_storage` = 's3' AND `s3_bucket` != '';
-- Migrate existing Google Drive remote configs into new table
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
SELECT
`id`,
CONCAT(`title`, ' - Google Drive'),
'google_drive',
1,
JSON_OBJECT(
'client_id', `gdrive_client_id`,
'client_secret', `gdrive_client_secret`,
'refresh_token', `gdrive_refresh_token`,
'folder_id', `gdrive_folder_id`
),
1,
NOW()
FROM `#__mokosuitebackup_profiles`
WHERE `remote_storage` = 'google_drive' AND `gdrive_client_id` != '';
-- Migrate existing FTP remote configs into new table
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
SELECT
`id`,
CONCAT(`title`, ' - FTP'),
'ftp',
1,
JSON_OBJECT(
'host', `ftp_host`,
'port', `ftp_port`,
'username', `ftp_username`,
'password', `ftp_password`,
'path', `ftp_path`,
'passive', `ftp_passive`,
'ssl', `ftp_ssl`
),
1,
NOW()
FROM `#__mokosuitebackup_profiles`
WHERE `remote_storage` = 'ftp' AND `ftp_host` != '';
@@ -348,7 +348,7 @@ class AjaxController extends BaseController
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
@@ -384,7 +384,7 @@ class AjaxController extends BaseController
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
@@ -416,7 +416,7 @@ class AjaxController extends BaseController
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.browse', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
@@ -713,6 +713,57 @@ class AjaxController extends BaseController
]);
}
/**
* Count backup records that would be purged before a given date.
* POST: task=ajax.countPurge&date=2025-01-01
*/
public function countPurge(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$date = $this->input->getString('date', '');
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
return;
}
$cutoff = $date . ' 00:00:00';
try {
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$count = (int) $db->loadResult();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
return;
}
$this->sendJson([
'error' => false,
'count' => $count,
'date' => $date,
]);
}
/**
* Compare two backup records side-by-side.
* POST: task=ajax.compareBackups&id1=123&id2=456
@@ -725,7 +776,7 @@ class AjaxController extends BaseController
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
@@ -828,6 +879,513 @@ class AjaxController extends BaseController
]);
}
// ------------------------------------------------------------------
// Remote Destinations CRUD
// ------------------------------------------------------------------
/**
* List remote destinations for a profile.
* POST: task=ajax.listRemotes&profile_id=1
*/
public function listRemotes(): 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;
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('profile_id') . ' = ' . $profileId)
->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC');
$db->setQuery($query);
$rows = $db->loadObjectList();
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
return;
}
// Decode JSON config and mask secrets
$items = [];
foreach ($rows as $row) {
$config = json_decode($row->config, true) ?: [];
// Mask sensitive fields so they never leave the server in list views
$masked = $this->maskSecrets($config, $row->type);
$items[] = [
'id' => (int) $row->id,
'profile_id' => (int) $row->profile_id,
'title' => $row->title,
'type' => $row->type,
'enabled' => (int) $row->enabled,
'keep_local' => (int) $row->keep_local,
'config' => $masked,
'ordering' => (int) $row->ordering,
];
}
$this->sendJson(['error' => false, 'items' => $items]);
}
/**
* Save (create or update) a remote destination.
* POST: task=ajax.saveRemote (JSON body or form fields)
*/
public function saveRemote(): 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;
}
$id = $this->input->getInt('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
$title = trim($this->input->getString('remote_title', ''));
$type = $this->input->getCmd('remote_type', 'sftp');
$enabled = $this->input->getInt('remote_enabled', 1);
$keepLocal = $this->input->getInt('remote_keep_local', 1);
$configRaw = $this->input->getString('remote_config', '{}');
if (!$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
return;
}
if (empty($title)) {
$this->sendJson(['error' => true, 'message' => 'Title is required']);
return;
}
$config = json_decode($configRaw, true);
if (!is_array($config)) {
$this->sendJson(['error' => true, 'message' => 'Invalid config JSON']);
return;
}
// If editing, merge secrets that were masked with __KEEP_EXISTING__
if ($id) {
$config = $this->mergeExistingSecrets($id, $config, $type);
}
$db = Factory::getDbo();
try {
$table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db);
if ($id) {
$table->load($id);
// Verify ownership
if ((int) $table->profile_id !== $profileId) {
$this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403);
return;
}
}
$table->profile_id = $profileId;
$table->title = $title;
$table->type = $type;
$table->enabled = $enabled ? 1 : 0;
$table->keep_local = $keepLocal ? 1 : 0;
$table->config = json_encode($config);
if (!$table->check() || !$table->store()) {
$this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']);
return;
}
$this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500);
}
}
/**
* Delete a remote destination.
* POST: task=ajax.deleteRemote&remote_id=1&profile_id=1
*/
public function deleteRemote(): 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;
}
$id = $this->input->getInt('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
if (!$id || !$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
return;
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($query);
$db->execute();
$this->sendJson(['error' => false, 'message' => 'Deleted']);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
}
}
/**
* Toggle enabled/disabled for a remote destination.
* POST: task=ajax.toggleRemote&remote_id=1&profile_id=1
*/
public function toggleRemote(): 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;
}
$id = $this->input->getInt('remote_id', 0);
$profileId = $this->input->getInt('profile_id', 0);
if (!$id || !$profileId) {
$this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']);
return;
}
try {
$db = Factory::getDbo();
// Load current state
$query = $db->getQuery(true)
->select($db->quoteName('enabled'))
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($query);
$current = $db->loadResult();
if ($current === null) {
$this->sendJson(['error' => true, 'message' => 'Remote not found'], 404);
return;
}
$newState = $current ? 0 : 1;
$update = $db->getQuery(true)
->update($db->quoteName('#__mokosuitebackup_remotes'))
->set($db->quoteName('enabled') . ' = ' . $newState)
->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s')))
->where($db->quoteName('id') . ' = ' . $id)
->where($db->quoteName('profile_id') . ' = ' . $profileId);
$db->setQuery($update);
$db->execute();
$this->sendJson(['error' => false, 'enabled' => $newState]);
} catch (\Exception $e) {
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
}
}
/**
* Mask sensitive values in a remote config array for display.
*/
private function maskSecrets(array $config, string $type): array
{
$secrets = [
'sftp' => ['password', 'passphrase', 'key_data'],
's3' => ['secret_key'],
'google_drive' => ['client_secret', 'refresh_token'],
];
$fields = $secrets[$type] ?? [];
foreach ($fields as $field) {
if (!empty($config[$field])) {
$config[$field] = '********';
}
}
return $config;
}
/**
* When updating a remote, merge back secrets that were masked in the form.
*/
private function mergeExistingSecrets(int $id, array $config, string $type): array
{
$secrets = [
'sftp' => ['password', 'passphrase', 'key_data'],
's3' => ['secret_key'],
'google_drive' => ['client_secret', 'refresh_token'],
];
$fields = $secrets[$type] ?? [];
$needsMerge = false;
foreach ($fields as $field) {
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
$needsMerge = true;
break;
}
}
if (!$needsMerge) {
return $config;
}
// Load existing config from DB
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('config'))
->from($db->quoteName('#__mokosuitebackup_remotes'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$existing = json_decode($db->loadResult() ?: '{}', true) ?: [];
} catch (\Exception $e) {
return $config;
}
foreach ($fields as $field) {
if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) {
$config[$field] = $existing[$field] ?? '';
}
}
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.
*/
@@ -165,6 +165,88 @@ class BackupsController extends AdminController
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
* Purge (delete) all completed backup records older than a given date.
*
* Deletes archive files, log files, and database records.
*
* @return void
*/
public function purge(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$cutoffDate = $this->input->getString('purge_date', '');
if (empty($cutoffDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $cutoffDate)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$cutoff = $cutoffDate . ' 00:00:00';
$db = $this->app->getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$ids = $db->loadColumn();
if (empty($ids)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'), 'warning');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$table = $this->getModel('Backup')->getTable();
$deleted = 0;
$errors = 0;
foreach ($ids as $id) {
if ($table->load((int) $id)) {
if ($table->delete()) {
$deleted++;
} else {
$errors++;
}
}
$table->reset();
}
if ($errors > 0) {
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_PARTIAL', $deleted, $errors), 'warning');
} else {
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_SUCCESS', $deleted));
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
* No-op target for the purge toolbar button.
*
* The toolbar button needs a task so Joomla does not complain,
* but the actual purge is triggered via the modal form which
* submits to backups.purge. This method simply redirects back.
*/
public function purgeModal(): void
{
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
* Verify integrity of a backup archive by re-computing SHA-256.
*/
@@ -259,7 +259,7 @@ class SnapshotsController extends AdminController
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
@@ -87,6 +87,12 @@ class BackupEngine
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
// Pass encryption password to 7z archiver (handles it natively via -p flag)
if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
$archiver->setEncryptionPassword($profile->encryption_password);
}
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
@@ -137,7 +143,19 @@ class BackupEngine
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
if ($sanitizePasswords) {
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
}
if ($sanitizeEmails) {
$this->log('User emails will be sanitized');
}
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount();
@@ -216,12 +234,14 @@ class BackupEngine
$encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) {
if ($archiveFormat !== 'zip') {
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
} else {
if ($archiveFormat === 'zip') {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
} elseif ($archiveFormat === '7z') {
$this->log('Archive encrypted with AES-256 (7z native encryption)');
} else {
$this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
}
}
@@ -268,53 +288,87 @@ class BackupEngine
$remoteFilename = '';
$uploadFailed = false;
// Step 3: Remote upload (if configured)
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
$remoteStorage = $profile->remote_storage ?? 'none';
/* Step 3: Remote upload — iterate all enabled destinations */
$remotes = $this->loadRemoteDestinations($db, $profileId);
if ($remoteStorage !== 'none') {
try {
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
if (!empty($remotes)) {
foreach ($remotes as $remote) {
try {
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
$params = json_decode($remote->params, true) ?: [];
$uploader = $this->createUploaderFromParams($remote->type, $params);
$result = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $archiveName;
$this->log(' Upload complete: ' . $result['message']);
// Upload standalone restore.php alongside the backup if in standalone mode
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$this->log('Uploading standalone restore.php...');
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
if ($restoreUpload['success']) {
$this->log('Standalone restore.php uploaded');
} else {
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
/* Upload standalone restore.php if in standalone mode */
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$uploader->upload($restoreScriptPath, 'restore.php');
}
} else {
$uploadFailed = true;
$this->log(' WARNING: Upload failed: ' . $result['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 {
} catch (\Throwable $e) {
$uploadFailed = true;
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
}
}
/* Delete local copy only when ALL remotes succeeded and profile says so */
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
@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']);
// Upload standalone restore.php alongside the backup if in standalone mode
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
$this->log('Uploading standalone restore.php...');
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
if ($restoreUpload['success']) {
$this->log('Standalone restore.php uploaded');
} else {
$this->log('WARNING: restore.php 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.');
}
} 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
$logContent = implode("\n", $this->log);
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
$logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
@@ -460,12 +514,15 @@ class BackupEngine
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
'7z' => new SevenZipArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
};
}
/**
* 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
{
@@ -478,6 +535,59 @@ class BackupEngine
};
}
/**
* Create a remote uploader from JSON params (multi-remote destinations).
*
* Builds a fake profile-like object from the params array so the existing
* uploader constructors work without modification.
*
* @param string $type Remote type: ftp, sftp, s3, google_drive
* @param array $params Key-value params decoded from the remote's JSON
*
* @return RemoteUploaderInterface
*/
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{
$fake = (object) $params;
return match ($type) {
'ftp' => new FtpUploader($fake),
'sftp' => new SftpUploader($fake),
'google_drive' => new GoogleDriveUploader($fake),
's3' => new S3Uploader($fake),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
}
/**
* 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);
return $db->loadObjectList() ?: [];
} catch (\Throwable $e) {
// Table does not exist yet (pre-migration) — fall back to legacy
return [];
}
}
/**
* Load the file manifest from the most recent full backup for this profile.
* Used by differential backups to determine which files changed.
@@ -565,6 +675,13 @@ class BackupEngine
return;
}
// 7z verification via CLI
if ($extension === '7z') {
$this->verify7zArchive($archivePath);
return;
}
// ZIP verification
$zip = new \ZipArchive();
@@ -626,6 +743,64 @@ class BackupEngine
}
}
/**
* Verify a 7z archive using the CLI binary.
*
* @param string $archivePath Absolute path to the .7z file
*
* @throws \RuntimeException If the archive fails verification
*/
private function verify7zArchive(string $archivePath): void
{
// Test the archive with 7z t (test integrity)
$candidates = PHP_OS_FAMILY === 'Windows'
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
$binary = null;
foreach ($candidates as $candidate) {
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
if (is_file($candidate) && is_executable($candidate)) {
$binary = $candidate;
break;
}
continue;
}
$whichCmd = PHP_OS_FAMILY === 'Windows'
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
$result = trim((string) shell_exec($whichCmd));
if ($result !== '' && is_executable($result)) {
$binary = $result;
break;
}
}
if ($binary === null) {
// Cannot verify without the binary — log warning but don't fail
$this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
return;
}
$cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new \RuntimeException(
'Archive integrity check failed: 7z test exited with code ' . $exitCode
. ': ' . implode("\n", array_slice($output, -5))
);
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -27,12 +27,35 @@ class DatabaseDumper
private int $tablesCount = 0;
/** @var bool Whether to sanitize user passwords */
private bool $sanitizePasswords = false;
/** @var bool Whether to preserve super admin password when sanitizing */
private bool $preserveSuperAdmin = false;
/** @var bool Whether to sanitize user emails */
private bool $sanitizeEmails = false;
/** @var bool Whether to clear session data */
private bool $sanitizeSessions = false;
/** Known invalid bcrypt hash used for sanitized passwords */
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
/**
* @param array $excludeTables Table names to exclude (with #__ prefix).
* Supports suffixes: :data-only, :structure-only.
* No suffix = exclude both (backward compatible).
* @param array $excludeTables Table names to exclude (with #__ prefix).
* @param bool $sanitizePasswords Replace user password hashes with invalid value
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
* @param bool $sanitizeSessions Skip session table data entirely
*/
public function __construct(array $excludeTables = [])
public function __construct(
array $excludeTables = [],
bool $sanitizePasswords = false,
bool $preserveSuperAdmin = false,
bool $sanitizeEmails = false,
bool $sanitizeSessions = false
)
{
foreach ($excludeTables as $entry) {
if (str_ends_with($entry, ':data-only')) {
@@ -43,6 +66,16 @@ class DatabaseDumper
$this->excludeBoth[] = $entry;
}
}
$this->sanitizePasswords = $sanitizePasswords;
$this->preserveSuperAdmin = $preserveSuperAdmin;
$this->sanitizeEmails = $sanitizeEmails;
$this->sanitizeSessions = $sanitizeSessions;
/* If session sanitization is on, auto-exclude session table data */
if ($sanitizeSessions) {
$this->excludeDataOnly[] = '#__session';
}
}
/**
@@ -154,6 +187,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -326,6 +360,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -351,6 +386,86 @@ class DatabaseDumper
return filesize($filePath) ?: 0;
}
/**
* Sanitize a row if it belongs to the users table and sanitization is enabled.
*
* Replaces the password column with an invalid hash so the backup
* cannot be used to extract user credentials.
*/
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
{
if ($abstractTable !== '#__users') {
return;
}
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
return;
}
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
$userId = (int) $row['id'];
/* Preserve super admin emails if preserving super admin */
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
$row['email'] = 'user' . $userId . '@sanitized.example.com';
}
}
if (!$this->sanitizePasswords || !isset($row['password'])) {
return;
}
if ($this->preserveSuperAdmin && isset($row['id'])) {
if ($this->isSuperAdmin((int) $row['id'], $db)) {
return;
}
}
$row['password'] = self::SANITIZED_HASH;
}
/**
* Check if a user ID belongs to the Super Users group (group_id = 8).
*/
private function isSuperAdmin(int $userId, object $db): bool
{
static $superAdminIds = null;
if ($superAdminIds === null) {
$prefix = $db->getPrefix();
try {
$db->setQuery(
$db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('user_id'))
->from($db->quoteName($prefix . 'user_usergroup_map'))
->where($db->quoteName('group_id') . ' = 8')
);
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
} catch (\Throwable $e) {
$superAdminIds = [];
}
}
return in_array($userId, $superAdminIds, true);
}
/**
* Check if passwords were sanitized (for use by callers to log the action).
*/
public function isPasswordSanitizationEnabled(): bool
{
return $this->sanitizePasswords;
}
/**
* Get the sentinel hash used for sanitized passwords.
*/
public static function getSanitizedHash(): string
{
return self::SANITIZED_HASH;
}
public function getTablesCount(): int
{
return $this->tablesCount;
@@ -376,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
function handleAction(string $action, array $data): array
{
return match ($action) {
'preflight' => actionPreflight(),
'extract' => actionExtract($data),
'testdb' => actionTestDb($data),
'database' => actionDatabase($data),
'config' => actionConfig($data),
'listAdmins' => actionListAdmins($data),
'resetAdmin' => actionResetAdmin($data),
'provision' => actionProvision($data),
'cleanup' => actionCleanup(),
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
'preflight' => actionPreflight(),
'extract' => actionExtract($data),
'scanTables' => actionScanTables(),
'testdb' => actionTestDb($data),
'database' => actionDatabase($data),
'config' => actionConfig($data),
'listAdmins' => actionListAdmins($data),
'resetAdmin' => actionResetAdmin($data),
'postRestore' => actionPostRestore($data),
'detectSanitized' => detectSanitizedPasswords($data),
'provision' => actionProvision($data),
'cleanup' => actionCleanup(),
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
};
}
@@ -551,6 +554,65 @@ function actionExtract(array $data): array
];
}
/**
* Parse database.sql and extract the list of table names.
* Returns table names using the abstract #__ prefix so the UI
* can display them before the user's target prefix is known.
*/
function actionScanTables(): array
{
$sqlFile = RESTORE_DIR . '/database.sql';
if (!is_file($sqlFile)) {
return ['success' => true, 'tables' => [], 'message' => 'No database.sql found'];
}
$sql = file_get_contents($sqlFile);
$tables = [];
// Match DROP TABLE IF EXISTS `#__tablename` or CREATE TABLE ... `#__tablename`
if (preg_match_all('/(?:DROP\s+TABLE\s+IF\s+EXISTS|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?)\s+`([^`]+)`/i', $sql, $matches)) {
foreach ($matches[1] as $name) {
if (!in_array($name, $tables, true)) {
$tables[] = $name;
}
}
}
// Sort alphabetically for easier scanning
sort($tables, SORT_STRING);
return [
'success' => true,
'tables' => $tables,
'count' => count($tables),
];
}
/**
* Determine which table a SQL statement belongs to.
* Returns the table name (with the prefix already applied) or empty string.
*/
function getStatementTable(string $stmt): string
{
// DROP TABLE IF EXISTS `prefix_tablename`
if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
// CREATE TABLE `prefix_tablename` or CREATE TABLE IF NOT EXISTS `prefix_tablename`
if (preg_match('/^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
// INSERT INTO `prefix_tablename`
if (preg_match('/^INSERT\s+INTO\s+`([^`]+)`/i', $stmt, $m)) {
return $m[1];
}
return '';
}
function actionTestDb(array $data): array
{
$host = $data['db_host'] ?? 'localhost';
@@ -608,10 +670,27 @@ function actionDatabase(array $data): array
// Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql);
// Decode per-table conflict resolution selections
// Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly
$tableResolutions = [];
if (!empty($data['table_resolutions'])) {
$decoded = json_decode($data['table_resolutions'], true);
if (is_array($decoded)) {
// Remap from abstract #__ names to the real prefix
foreach ($decoded as $abstractName => $mode) {
$realName = str_replace('#__', $prefix, $abstractName);
$tableResolutions[$realName] = $mode;
}
}
}
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
$errorList = [];
$skipped = 0;
foreach ($parts as $part) {
$part = trim($part);
@@ -620,6 +699,42 @@ function actionDatabase(array $data): array
continue;
}
// Determine which table this statement belongs to
$table = getStatementTable($part);
$mode = $tableResolutions[$table] ?? 'replace';
// Apply conflict resolution per table
if ($mode === 'skip') {
$skipped++;
continue;
}
$isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part);
$isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part);
$isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part);
if ($mode === 'merge') {
// Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part);
}
} elseif ($mode === 'dataonly') {
/* Skip DROP and CREATE; use REPLACE INTO for data (overwrites on duplicate key) */
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'REPLACE INTO', $part);
}
}
// mode === 'replace' => execute everything as-is (default)
try {
$pdo->exec($part);
$statements++;
@@ -634,11 +749,22 @@ function actionDatabase(array $data): array
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
$msg = "Executed {$statements} statements";
if ($skipped > 0) {
$msg .= " ({$skipped} skipped)";
}
if ($errors > 0) {
$msg .= " ({$errors} warnings)";
}
return [
'success' => ($statements > 0 || $errors === 0),
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
'message' => $msg,
'statements' => $statements,
'errors' => $errors,
'skipped' => $skipped,
'errorList' => $errorList,
];
}
@@ -989,6 +1115,128 @@ function actionResetAdmin(array $data): array
return ['success' => true, 'message' => 'Admin password updated successfully'];
}
function actionPostRestore(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = [];
foreach ($tasks as $task) {
try {
switch ($task) {
case 'reset_passwords':
/* Set all user passwords to a random temporary hash, block non-admin users */
$tempPassword = bin2hex(random_bytes(8)); /* 16-char random hex */
// clear activation tokens, and force password reset on next login.
$tempHash = password_hash($tempPassword, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1"
);
$stmt->execute([$tempHash]);
$affected = $stmt->rowCount();
$results[] = "All {$affected} user password(s) reset to temporary password ({$tempPassword}) with forced reset";
break;
case 'reset_hits':
$pdo->exec("UPDATE {$prefix}content SET hits = 0");
$results[] = 'Content hits reset to 0';
break;
case 'clear_versions':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}history");
$results[] = 'Content version history cleared';
} catch (PDOException $e) {
$results[] = 'Version history: table not found (skipped)';
}
break;
case 'clear_sessions':
$pdo->exec("TRUNCATE TABLE {$prefix}session");
$results[] = 'Sessions cleared';
break;
case 'clear_cache':
// Clear Joomla cache tables
foreach (['cache', 'cache_extension'] as $tbl) {
try {
$pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}");
} catch (PDOException $e) {
// Table may not exist
}
}
// Delete files in cache/ directory
$cacheDir = RESTORE_DIR . '/cache';
$cacheCount = 0;
if (is_dir($cacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
// Also clear administrator/cache/
$adminCacheDir = RESTORE_DIR . '/administrator/cache';
if (is_dir($adminCacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
$results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed";
break;
default:
$results[] = "Unknown task: {$task}";
}
} catch (Throwable $e) {
$results[] = "Error ({$task}): " . $e->getMessage();
}
}
return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed'];
}
/**
* Detect whether the database contains sanitized sentinel password hashes.
* Returns true if any user has the MokoSuiteBackup sanitized placeholder hash.
*/
function detectSanitizedPasswords(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?");
$stmt->execute([$sentinel]);
$count = (int) $stmt->fetchColumn();
return ['success' => true, 'detected' => $count > 0, 'count' => $count];
}
function actionProvision(array $data): array
{
$pdo = getDbConnection($data);
@@ -1233,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-steps" id="stepBar">
<div class="mr-step active" data-step="1"><span class="mr-num">1</span>Checks</div>
<div class="mr-step" data-step="2"><span class="mr-num">2</span>Extract</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Database</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Admin</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Provisioning</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Tables</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Database</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Configuration</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Admin</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Post-Restore</div>
<div class="mr-step" data-step="8"><span class="mr-num">8</span>Provisioning</div>
<div class="mr-step" data-step="9"><span class="mr-num">9</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
@@ -1292,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
</div>
<!-- Step 3: Database -->
<!-- Step 3: Table Conflict Resolution -->
<div class="mr-panel" id="panel3">
<h2>Table Conflict Resolution</h2>
<p class="mr-desc">Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.</p>
<div style="margin-bottom:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('replace')">All Replace</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('skip')">All Skip</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('merge')">All Merge</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="presetExceptUsers()">Everything except users</button>
</div>
<div class="mr-alert mr-alert-info" style="font-size:0.85rem">
<strong>Modes:</strong>
<strong>Replace</strong> = drop + recreate + insert (default).
<strong>Skip</strong> = ignore entirely.
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
</div>
<input type="hidden" id="tableResolutions" value="{}">
<div class="mr-status" id="tableScanStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnTablesContinue" onclick="goStep(4)">Continue to Database</button>
</div>
</div>
<!-- Step 4: Database -->
<div class="mr-panel" id="panel4">
<h2>Database Configuration</h2>
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
<div class="mr-row">
@@ -1312,13 +1590,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
<div class="mr-status" id="dbStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div>
</div>
<!-- Step 4: Site Configuration -->
<div class="mr-panel" id="panel4">
<!-- Step 5: Site Configuration -->
<div class="mr-panel" id="panel5">
<h2>Site Configuration</h2>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
@@ -1361,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="configStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div>
</div>
<!-- Step 5: Admin Password Reset -->
<div class="mr-panel" id="panel5">
<!-- Step 6: Admin Password Reset -->
<div class="mr-panel" id="panel6">
<h2>Super Admin Password</h2>
<p class="mr-desc">Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.</p>
<div class="mr-field">
@@ -1380,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="adminStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div>
</div>
</div>
<!-- Step 6: Client Provisioning -->
<div class="mr-panel" id="panel6">
<!-- Step 7: Post-Restore Actions -->
<div class="mr-panel" id="panel7">
<h2>Post-Restore Actions</h2>
<p class="mr-desc">Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.</p>
<div class="mr-alert mr-alert-warn" id="postRestoreSanitizedWarn" style="display:none">
<strong>Sanitized passwords detected!</strong> This backup contains placeholder password hashes that will prevent all users from logging in. The "Reset all user passwords" option below is strongly recommended.
</div>
<ul class="mr-provision-list" id="postRestoreList">
<li><input type="checkbox" class="post-restore-task" id="prResetPasswords" value="reset_passwords"><span>Reset all user passwords</span><span class="mr-provision-desc">Set to random temporary password and force reset on next login</span></li>
<li><input type="checkbox" class="post-restore-task" value="reset_hits"><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_versions"><span>Clear version history</span><span class="mr-provision-desc">Truncate the content version history table</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_sessions" checked><span>Clear sessions</span><span class="mr-provision-desc">Remove all active user sessions</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate cache tables and delete cache files</span></li>
</ul>
<div class="mr-status" id="postRestoreStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(8)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnPostRestore" onclick="runPostRestore()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 8: Client Provisioning -->
<div class="mr-panel" id="panel8">
<h2>Client Provisioning</h2>
<p class="mr-desc">Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.</p>
<ul class="mr-provision-list">
@@ -1404,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</ul>
<div class="mr-status" id="provisionStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(9)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 7: Complete -->
<div class="mr-panel" id="panel7">
<!-- Step 9: Complete -->
<div class="mr-panel" id="panel9">
<h2>Installation Complete</h2>
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
<div class="mr-alert mr-alert-success">
@@ -1484,7 +1786,9 @@ function goStep(n) {
else if (sn < n) s.classList.add('done');
});
if (n === 5) loadAdmins();
if (n === 3) scanTables();
if (n === 6) loadAdmins();
if (n === 7) checkSanitizedPasswords();
}
function setStatus(id, msg, type) {
@@ -1621,7 +1925,111 @@ async function runExtract() {
}
}
// Step 3
// Step 3: Table Conflict Resolution
let tableList = [];
async function scanTables() {
const container = document.getElementById('tableResolutionList');
// Only scan once
if (tableList.length > 0) return;
log('Scanning database.sql for table names...');
const r = await post('scanTables');
if (!r.success || !r.tables || r.tables.length === 0) {
container.innerHTML = '<div style="padding:1rem;color:#94a3b8;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error');
log(r.message || 'No tables found');
return;
}
tableList = r.tables;
log('Found ' + r.count + ' tables');
setStatus('tableScanStatus', 'Found ' + r.count + ' tables', 'success');
renderTableList();
}
function renderTableList() {
const container = document.getElementById('tableResolutionList');
container.innerHTML = '';
var resolutions = {};
tableList.forEach(function(name) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;';
var label = document.createElement('span');
label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;';
label.textContent = name;
var sel = document.createElement('select');
sel.dataset.table = name;
sel.className = 'table-mode-select';
sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;';
var modes = [
['replace', 'Replace'],
['skip', 'Skip'],
['merge', 'Merge'],
['dataonly', 'Data Only']
];
modes.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m[0];
opt.textContent = m[1];
sel.appendChild(opt);
});
sel.addEventListener('change', updateTableResolutions);
row.appendChild(label);
row.appendChild(sel);
container.appendChild(row);
resolutions[name] = 'replace';
});
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
}
function updateTableResolutions() {
var resolutions = {};
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
resolutions[sel.dataset.table] = sel.value;
});
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
}
function setAllTableMode(mode) {
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
sel.value = mode;
});
updateTableResolutions();
log('Set all tables to: ' + mode);
}
function presetExceptUsers() {
var userTables = ['#__users', '#__user_usergroup_map', '#__user_profiles'];
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
var tableName = sel.dataset.table;
if (userTables.indexOf(tableName) !== -1) {
sel.value = 'skip';
} else {
sel.value = 'replace';
}
});
updateTableResolutions();
log('Preset: Replace all except user tables (skipped)');
}
// Step 4
function getDbParams() {
return {
db_host: document.getElementById('dbHost').value,
@@ -1647,7 +2055,12 @@ async function runDatabase() {
log('Importing database...');
dbConfig = getDbParams();
const r = await post('database', dbConfig);
// Include table conflict resolution selections
var tableRes = document.getElementById('tableResolutions');
var dbParams = Object.assign({}, dbConfig, {
table_resolutions: tableRes ? tableRes.value : '{}'
});
const r = await post('database', dbParams);
document.getElementById('dbProgress').style.width = '100%';
setBtnLoading(btn, false);
@@ -1655,17 +2068,20 @@ async function runDatabase() {
if (r.success) {
setStatus('dbStatus', r.message, 'success');
log(r.message);
if (r.skipped && r.skipped > 0) {
log(' Skipped ' + r.skipped + ' statements due to conflict resolution');
}
if (r.errorList && r.errorList.length > 0) {
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
}
setTimeout(function() { goStep(4); }, 500);
setTimeout(function() { goStep(5); }, 500);
} else {
setStatus('dbStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 4
// Step 5
async function runConfig() {
const btn = document.getElementById('btnConfig');
setBtnLoading(btn, true);
@@ -1686,14 +2102,14 @@ async function runConfig() {
if (r.success) {
setStatus('configStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(5); }, 500);
setTimeout(function() { goStep(6); }, 500);
} else {
setStatus('configStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 5
// Step 6
async function loadAdmins() {
const sel = document.getElementById('adminSelect');
while (sel.firstChild) sel.removeChild(sel.firstChild);
@@ -1738,20 +2154,65 @@ async function runResetAdmin() {
if (r.success) {
setStatus('adminStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(6); }, 500);
setTimeout(function() { goStep(7); }, 500);
} else {
setStatus('adminStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 6
// Step 7: Post-Restore
async function checkSanitizedPasswords() {
log('Checking for sanitized password hashes...');
try {
const r = await post('detectSanitized', dbConfig);
if (r.success && r.detected) {
document.getElementById('postRestoreSanitizedWarn').style.display = '';
document.getElementById('prResetPasswords').checked = true;
log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords');
} else {
document.getElementById('postRestoreSanitizedWarn').style.display = 'none';
log('No sanitized passwords detected');
}
} catch (e) {
log('Could not check for sanitized passwords: ' + e.message);
}
}
async function runPostRestore() {
const btn = document.getElementById('btnPostRestore');
const tasks = [];
document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(8); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' post-restore task(s)...');
const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) });
const r = await post('postRestore', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('postRestoreStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(8); }, 500);
} else {
setStatus('postRestoreStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 8
async function runProvision() {
const btn = document.getElementById('btnProvision');
const tasks = [];
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(7); return; }
if (tasks.length === 0) { goStep(9); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' provisioning tasks...');
@@ -1764,14 +2225,14 @@ async function runProvision() {
if (r.success) {
setStatus('provisionStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(7); }, 500);
setTimeout(function() { goStep(9); }, 500);
} else {
setStatus('provisionStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 7
// Step 9
async function runCleanup() {
log('Cleaning up restore files...');
const r = await post('cleanup');
@@ -51,7 +51,32 @@ class PlaceholderResolver
public function __construct(object $profile)
{
$now = new \DateTimeImmutable('now');
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
/* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (empty($rawHost) || $rawHost === 'localhost') {
try {
$app = Factory::getApplication();
$liveSite = $app->get('live_site', '');
if (!empty($liveSite)) {
$parsed = parse_url($liveSite, PHP_URL_HOST);
if (!empty($parsed)) {
$rawHost = $parsed;
}
}
} catch (\Throwable $e) {
/* fallback */
}
}
if (empty($rawHost)) {
$rawHost = php_uname('n');
}
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
$siteName = '';
@@ -0,0 +1,260 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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;
/**
* 7z archiver using the 7za/7z CLI binary.
*
* Requires p7zip-full (Linux) or 7-Zip (Windows) to be installed on the server.
* Supports native AES-256 encryption via the -p flag.
*/
class SevenZipArchiver implements ArchiverInterface
{
/** @var string Absolute path to the target archive */
private string $archivePath = '';
/** @var string[] Absolute paths of files to add */
private array $filePaths = [];
/** @var string[] Corresponding local names inside the archive */
private array $localNames = [];
/** @var string[] Temp files created by addFromString() that must be cleaned up */
private array $tempFiles = [];
/** @var string Optional encryption password */
private string $encryptionPassword = '';
/**
* Set the encryption password for the archive.
*
* @param string $password Password for AES-256 encryption
*/
public function setEncryptionPassword(string $password): void
{
$this->encryptionPassword = $password;
}
public function open(string $path): void
{
$this->archivePath = $path;
$this->filePaths = [];
$this->localNames = [];
$this->tempFiles = [];
// Remove existing archive to avoid appending to stale data
if (is_file($path)) {
@unlink($path);
}
}
public function addFromString(string $localName, string $contents): void
{
// Write to a temp file so 7z can read it from disk
$tempDir = \dirname($this->archivePath);
$tempFile = $tempDir . '/.7z-tmp-' . md5($localName . microtime(true)) . '-' . basename($localName);
if (file_put_contents($tempFile, $contents) === false) {
throw new \RuntimeException('SevenZipArchiver: cannot write temp file: ' . $tempFile);
}
$this->tempFiles[] = $tempFile;
$this->filePaths[] = $tempFile;
$this->localNames[] = $localName;
}
public function addFile(string $filePath, string $localName): void
{
$this->filePaths[] = $filePath;
$this->localNames[] = $localName;
}
public function close(): void
{
try {
$this->buildArchive();
} finally {
// Always clean up temp files
foreach ($this->tempFiles as $tempFile) {
if (is_file($tempFile)) {
@unlink($tempFile);
}
}
$this->tempFiles = [];
}
}
public function getExtension(): string
{
return '7z';
}
/**
* Build the 7z archive using the CLI binary.
*
* Writes a list file mapping local names to absolute paths, then invokes
* 7za/7z to create the archive. Uses stdin rename pairs for correct
* internal paths.
*/
private function buildArchive(): void
{
$binary = $this->findBinary();
if ($binary === null) {
throw new \RuntimeException(
'SevenZipArchiver: 7z/7za binary not found. '
. 'Install p7zip-full (Linux) or 7-Zip (Windows).'
);
}
if (empty($this->filePaths)) {
throw new \RuntimeException('SevenZipArchiver: no files to archive');
}
// Strategy: create a temporary staging directory with the correct
// directory structure, symlink or copy files, then archive the
// staging directory. This gives us correct internal paths.
$stagingDir = \dirname($this->archivePath) . '/.7z-staging-' . md5($this->archivePath . microtime(true));
if (!mkdir($stagingDir, 0755, true)) {
throw new \RuntimeException('SevenZipArchiver: cannot create staging directory: ' . $stagingDir);
}
try {
// Create the directory structure and link/copy files
foreach ($this->filePaths as $i => $sourcePath) {
$localName = $this->localNames[$i];
$targetPath = $stagingDir . '/' . $localName;
$targetDir = \dirname($targetPath);
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) {
throw new \RuntimeException('SevenZipArchiver: cannot create directory: ' . $targetDir);
}
// Use symlink where possible (faster, no disk usage), fall back to copy
if (@symlink($sourcePath, $targetPath) === false) {
if (!copy($sourcePath, $targetPath)) {
throw new \RuntimeException('SevenZipArchiver: cannot copy file: ' . $sourcePath);
}
}
}
// Build command
$cmd = escapeshellarg($binary)
. ' a'
. ' -t7z'
. ' -mx=5'
. ' -mhe=on'
. ' ' . escapeshellarg($this->archivePath)
. ' ' . escapeshellarg($stagingDir . '/*');
// Add encryption if password is set
if ($this->encryptionPassword !== '') {
$cmd .= ' -p' . escapeshellarg($this->encryptionPassword);
}
// Suppress interactive prompts
$cmd .= ' -y';
// Redirect stderr to stdout for capture
$cmd .= ' 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
$outputStr = implode("\n", $output);
throw new \RuntimeException(
'SevenZipArchiver: 7z exited with code ' . $exitCode . ': ' . $outputStr
);
}
if (!is_file($this->archivePath)) {
throw new \RuntimeException('SevenZipArchiver: archive was not created: ' . $this->archivePath);
}
// The archive contains paths relative to the staging dir.
// We need to verify that the internal structure doesn't include
// the staging dir name as a prefix. If 7z was given staging/*,
// the paths inside should be correct (relative to staging).
} finally {
// Remove staging directory
$this->removeDirectory($stagingDir);
}
}
/**
* Locate the 7z or 7za binary.
*
* @return string|null Absolute path to binary, or null if not found
*/
private function findBinary(): ?string
{
// Check common binary names
$candidates = PHP_OS_FAMILY === 'Windows'
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
foreach ($candidates as $candidate) {
// If it's an absolute path, check file existence
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
if (is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
continue;
}
// Use 'which' / 'where' to find in PATH
$whichCmd = PHP_OS_FAMILY === 'Windows'
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
$result = trim((string) shell_exec($whichCmd));
if ($result !== '' && is_executable($result)) {
return $result;
}
}
return null;
}
/**
* Recursively remove a directory and its contents.
*/
private function removeDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
// Remove symlinks and files
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
}
@@ -73,6 +73,10 @@ class SteppedBackupEngine
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
// Load multi-remote destinations from the remotes table
$session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId);
$session->remoteIndex = 0;
// Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver($profile);
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
@@ -81,9 +85,21 @@ class SteppedBackupEngine
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
}
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$archiveFormat = $profile->archive_format ?? 'zip';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
// The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
// supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
if ($archiveFormat !== 'zip') {
return [
'error' => true,
'message' => 'The stepped backup engine only supports ZIP format. '
. 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
];
}
$archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
@@ -135,13 +151,22 @@ class SteppedBackupEngine
}
$totalSteps += 1; // finalize step
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload 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;
}
$session->totalSteps = $totalSteps;
$session->currentStep = 1;
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description);
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')');
// Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning);
@@ -379,7 +404,17 @@ class SteppedBackupEngine
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->currentStep++;
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
// Determine next phase: multi-remote, legacy single-remote, or complete
$hasMultiRemote = !empty($session->remoteDestinations);
$hasLegacyRemote = $session->remoteStorage !== 'none';
if ($hasMultiRemote || $hasLegacyRemote) {
$session->phase = 'upload';
} else {
$session->phase = 'complete';
}
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
$session->log('Archive finalized: ' . $sizeHuman);
@@ -390,6 +425,10 @@ 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.
*/
private function stepUpload(SteppedSession $session): void
{
@@ -397,62 +436,126 @@ class SteppedBackupEngine
$remoteFilename = '';
$uploadFailed = false;
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
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();
if (!empty($session->remoteDestinations)) {
// ── Multi-remote path ──────────────────────────────────
$index = $session->remoteIndex;
$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),
};
if ($index >= count($session->remoteDestinations)) {
// All remotes processed — move to complete
$session->phase = 'complete';
$session->statusMessage = 'All remote uploads finished';
$this->completeRecord($session);
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
return;
}
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
$remote = (object) $session->remoteDestinations[$index];
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
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);
if ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log(' Upload complete: ' . $result['message']);
} else {
$uploadFailed = true;
$session->log(' WARNING: Upload failed: ' . $result['message']);
}
} else {
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log('WARNING: Remote upload failed: ' . $result['message']);
$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)) {
// 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 (!$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.');
}
} 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';
$this->completeRecord($session, $uploadFailed);
}
// 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';
$this->completeRecord($session, $uploadFailed);
}
/**
@@ -717,4 +820,58 @@ class SteppedBackupEngine
return $tables;
}
/**
* 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 array Array of remote destination rows (as associative arrays for JSON serialization)
*/
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);
// 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 [];
}
}
/**
* Create a remote uploader from JSON params (multi-remote destinations).
*
* Builds a fake profile-like object from the params array so the existing
* uploader constructors work without modification.
*
* @param string $type Remote type: ftp, sftp, s3, google_drive
* @param array $params Key-value params decoded from the remote's JSON
*
* @return RemoteUploaderInterface
*/
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
{
$fake = (object) $params;
return match ($type) {
'ftp' => new FtpUploader($fake),
'sftp' => new SftpUploader($fake),
'google_drive' => new GoogleDriveUploader($fake),
's3' => new S3Uploader($fake),
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
};
}
}
@@ -55,6 +55,10 @@ class SteppedSession
public bool $remoteKeepLocal = true;
public string $encryptionPassword = '';
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
public array $remoteDestinations = [];
public int $remoteIndex = 0;
// Progress
public int $totalSteps = 0;
public int $currentStep = 0;
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
}
// Build placeholder map for JS resolution
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
/* Resolve hostname: prefer HTTP_HOST, then Joomla live_site config, then system hostname */
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
if (empty($rawHost) || $rawHost === 'localhost') {
try {
$liveSite = Factory::getApplication()->get('live_site', '');
if (!empty($liveSite)) {
$parsed = parse_url($liveSite, PHP_URL_HOST);
if (!empty($parsed)) {
$rawHost = $parsed;
}
}
} catch (\Throwable $e) {
/* fallback */
}
}
if (empty($rawHost)) {
$rawHost = php_uname('n');
}
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
$siteName = '';
try {
@@ -0,0 +1,253 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
title="Browse directories on the remote SFTP server">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote
</button>
</div>
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_sftpModalLabel">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse Remote SFTP Directory
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="{$id}_sftpStatus" class="mb-2">
<small class="text-muted">Click "Browse Remote" to connect...</small>
</div>
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
/
</div>
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
</div>
<div class="mt-2">
<small class="text-muted">
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
<br>SFTP credentials must be saved in the profile before browsing.
</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
<span class="icon-checkmark" aria-hidden="true"></span>
Select This Directory
</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var fieldId = '{$id}';
var input = document.getElementById(fieldId);
var browseBtn = document.getElementById(fieldId + '_browseBtn');
var modalEl = document.getElementById(fieldId + '_sftpModal');
var treeEl = document.getElementById(fieldId + '_sftpTree');
var statusEl = document.getElementById(fieldId + '_sftpStatus');
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
var currentPath = '/';
function getProfileId() {
var el = document.getElementById('jform_id');
return el ? parseInt(el.value, 10) || 0 : 0;
}
function showModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
}
}
function hideModal() {
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
}
/**
* Set the status message using safe DOM methods (no innerHTML).
* @param {string} cssClass - CSS class for the small element
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
* @param {string} text - Plain text message
*/
function setStatus(cssClass, iconClass, text) {
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
var small = document.createElement('small');
small.className = cssClass;
if (iconClass) {
var icon = document.createElement('span');
icon.className = iconClass;
icon.setAttribute('aria-hidden', 'true');
small.appendChild(icon);
small.appendChild(document.createTextNode(' '));
}
small.appendChild(document.createTextNode(text));
statusEl.appendChild(small);
}
function loadSftpDir(path) {
currentPath = path;
currentEl.textContent = path;
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
var profileId = getProfileId();
if (!profileId) {
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
return;
}
var form = new URLSearchParams();
form.append('task', 'ajax.browseSftpDir');
form.append('profile_id', profileId);
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) {
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
return r.json();
})
.then(function(data) {
if (data.error) {
setStatus('text-danger', 'icon-warning', data.message || 'Error');
return;
}
var count = data.dirs ? data.dirs.length : 0;
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
currentPath = data.current || path;
currentEl.textContent = currentPath;
renderSftpTree(data);
})
.catch(function(err) {
setStatus('text-danger', 'icon-warning', err.message);
});
}
function renderSftpTree(data) {
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
/* Parent / back button */
if (data.parent !== null && data.parent !== undefined) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' .. (parent directory)'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(data.parent);
});
list.appendChild(up);
}
/* Directory entries */
var dirs = data.dirs || [];
dirs.forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var folderIcon = document.createElement('span');
folderIcon.className = 'icon-folder';
folderIcon.setAttribute('aria-hidden', 'true');
item.appendChild(folderIcon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
loadSftpDir(dir.path);
});
/* Double-click to select and close */
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = dir.path;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
list.appendChild(item);
});
if (dirs.length === 0) {
var empty = document.createElement('div');
empty.className = 'list-group-item text-muted py-2';
empty.textContent = '(no subdirectories)';
list.appendChild(empty);
}
treeEl.appendChild(list);
}
/* Browse button click */
browseBtn.addEventListener('click', function(e) {
e.preventDefault();
var startPath = input.value.trim() || '/';
showModal();
loadSftpDir(startPath);
});
/* Select button — use the current directory */
selectBtn.addEventListener('click', function(e) {
e.preventDefault();
input.value = currentPath;
input.dispatchEvent(new Event('change', { bubbles: true }));
hideModal();
});
})();
</script>
HTML;
}
}
@@ -0,0 +1,67 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\AdminModel;
class RemoteModel extends AdminModel
{
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokosuitebackup.remote',
'remote',
['control' => 'jform', 'load_data' => $loadData]
);
return $form ?: false;
}
protected function loadFormData(): object
{
$data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.remote.data', []);
if (empty($data)) {
$data = $this->getItem();
}
return is_array($data) ? (object) $data : $data;
}
public function getTable($name = 'Remote', $prefix = 'Administrator', $options = [])
{
return parent::getTable($name, $prefix, $options);
}
/**
* Get all enabled remotes for a given profile.
*
* @param int $profileId The profile ID
*
* @return array Array of remote objects
*/
public function getEnabledByProfile(int $profileId): array
{
$db = $this->getDatabase();
$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() ?: [];
}
}
@@ -0,0 +1,88 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
use Joomla\Database\QueryInterface;
class RemotesModel extends ListModel
{
public function __construct($config = [])
{
if (empty($config['filter_fields'])) {
$config['filter_fields'] = [
'id', 'a.id',
'profile_id', 'a.profile_id',
'title', 'a.title',
'type', 'a.type',
'enabled', 'a.enabled',
'ordering', 'a.ordering',
];
}
parent::__construct($config);
}
protected function getListQuery(): QueryInterface
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitebackup_remotes', 'a'));
// Join profile title
$query->select($db->quoteName('p.title', 'profile_title'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = a.profile_id');
// Filter by profile
$profileId = $this->getState('filter.profile_id');
if (is_numeric($profileId)) {
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
}
// Filter by type
$type = $this->getState('filter.type');
if (!empty($type)) {
$query->where($db->quoteName('a.type') . ' = ' . $db->quote($type));
}
// Filter by enabled
$enabled = $this->getState('filter.enabled');
if (is_numeric($enabled)) {
$query->where($db->quoteName('a.enabled') . ' = ' . (int) $enabled);
}
// Filter by search
$search = $this->getState('filter.search');
if (!empty($search)) {
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
}
$orderCol = $this->state->get('list.ordering', 'a.ordering');
$orderDir = $this->state->get('list.direction', 'ASC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
return $query;
}
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
{
parent::populateState($ordering, $direction);
}
}
@@ -0,0 +1,94 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @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\Table;
defined('_JEXEC') or die;
use Joomla\CMS\Table\Table;
use Joomla\Database\DatabaseDriver;
class RemoteTable extends Table
{
public function __construct(DatabaseDriver $db)
{
parent::__construct('#__mokosuitebackup_remotes', 'id', $db);
}
public function check(): bool
{
if (empty($this->profile_id)) {
$this->setError('Profile ID is required.');
return false;
}
$validTypes = ['sftp', 's3', 'google_drive', 'ftp'];
if (empty($this->type) || !\in_array($this->type, $validTypes, true)) {
$this->setError('Invalid remote type. Must be one of: ' . implode(', ', $validTypes));
return false;
}
if (empty($this->title)) {
$this->title = ucfirst(str_replace('_', ' ', $this->type)) . ' Remote';
}
// Ensure params is valid JSON
if (!empty($this->params) && \is_string($this->params)) {
$decoded = json_decode($this->params);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->setError('Remote params must be valid JSON.');
return false;
}
}
$now = date('Y-m-d H:i:s');
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
$this->created = $now;
}
$this->modified = $now;
return true;
}
/**
* Get the params as a decoded object.
*
* @return object
*/
public function getParams(): object
{
if (empty($this->params)) {
return (object) [];
}
$decoded = json_decode($this->params);
return \is_object($decoded) ? $decoded : (object) [];
}
/**
* Set params from an array or object, encoding to JSON.
*
* @param array|object $params The parameters to encode
*
* @return void
*/
public function setParams(array|object $params): void
{
$this->params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}
@@ -272,6 +272,6 @@ HTACCESS;
*/
public static function logPathFromArchive(string $archivePath): string
{
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
return preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
}
}
@@ -120,9 +120,11 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
}
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
}
if ($user->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
}
@@ -130,6 +132,10 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
}
if ($user->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
}
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
ToolbarHelper::preferences('com_mokosuitebackup');
}
@@ -695,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Purge Backups Modal -->
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
<?php if ($canDelete) : ?>
<div id="mb-purge-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
</h4>
<button type="button" class="btn-close mb-purge-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
<div style="padding:1.5rem;">
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
<div class="mb-3">
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
</div>
<div id="mb-purge-count-wrapper" style="display:none;">
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
</div>
<div id="mb-purge-none-wrapper" style="display:none;">
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-purge-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<?php endif; ?>
<!-- Backup Comparison Modal -->
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
@@ -863,3 +902,114 @@ $listDirn = $this->escape($this->state->get('list.direction'));
});
})();
</script>
<?php if ($canDelete) : ?>
<script>
(function() {
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
var purgeCountTimer = null;
// Intercept Purge toolbar button to show the modal
document.addEventListener('DOMContentLoaded', function() {
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;
document.getElementById('mb-purge-modal').style.display = 'block';
return false;
}, true);
}
// Date change triggers count lookup with debounce
var dateInput = document.getElementById('mb-purge-date');
if (dateInput) {
dateInput.addEventListener('change', function() {
if (purgeCountTimer) clearTimeout(purgeCountTimer);
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
});
}
// Close modal
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-purge-modal' || e.target.classList.contains('mb-purge-close')) {
document.getElementById('mb-purge-modal').style.display = 'none';
}
});
// Confirm on submit
var purgeForm = document.getElementById('mb-purge-form');
if (purgeForm) {
purgeForm.addEventListener('submit', function(e) {
var msg = document.getElementById('mb-purge-count-msg').textContent;
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
e.preventDefault();
}
});
}
});
function fetchPurgeCount() {
var dateVal = document.getElementById('mb-purge-date').value;
var countWrapper = document.getElementById('mb-purge-count-wrapper');
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
var countMsg = document.getElementById('mb-purge-count-msg');
var submitBtn = document.getElementById('mb-purge-submit');
if (!dateVal) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
return;
}
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
var form = new URLSearchParams();
form.append('task', 'ajax.countPurge');
form.append('date', dateVal);
form.append(PURGE_TOKEN, '1');
fetch(PURGE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
countMsg.textContent = data.message || 'Error';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
} else if (data.count === 0) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'block';
submitBtn.disabled = true;
} else {
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
countMsg.textContent = text.replace('%d', data.count);
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = false;
}
})
.catch(function(err) {
countMsg.textContent = 'Error: ' + err.message;
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
});
}
})();
</script>
<?php endif; ?>
@@ -13,11 +13,15 @@ defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive');
$profileId = (int) $this->item->id;
$token = Session::getFormToken();
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . (int) $this->item->id); ?>"
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . $profileId); ?>"
method="post" name="adminForm" id="adminForm" class="form-validate">
<div class="main-card">
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
<div class="row">
<div class="col-lg-9">
<?php echo $this->form->renderFieldset('remote'); ?>
<?php echo $this->form->renderFieldset('ftp'); ?>
<?php echo $this->form->renderFieldset('google_drive'); ?>
<?php echo $this->form->renderFieldset('s3'); ?>
<div class="col-lg-12">
<?php // ---- Remote Destinations (multi-remote) ---- ?>
<?php if ($profileId): ?>
<div id="mokoRemoteDestinations" class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS'); ?></h3>
<button type="button" class="btn btn-success btn-sm" id="btnAddRemote">
<span class="icon-plus" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?>
</button>
</div>
<table class="table" id="remoteDestTable">
<thead>
<tr>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th style="width:120px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
<th style="width:100px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS'); ?></th>
<th style="width:160px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?></th>
</tr>
</thead>
<tbody id="remoteDestBody">
<tr id="remoteDestLoading">
<td colspan="4" class="text-center text-muted">
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
</td>
</tr>
</tbody>
</table>
<p class="text-muted small" id="remoteDestEmpty" style="display:none;">
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
</p>
</div>
<hr>
<?php endif; ?>
<?php // ---- Legacy single-remote fields ---- ?>
<div id="legacyRemoteFields">
<div class="alert alert-info small" id="legacyRemoteNote" style="display:none;">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE'); ?>
</div>
<?php echo $this->form->renderFieldset('remote'); ?>
<?php echo $this->form->renderFieldset('ftp'); ?>
<?php echo $this->form->renderFieldset('google_drive'); ?>
<?php echo $this->form->renderFieldset('s3'); ?>
</div>
</div>
</div>
<?php echo HTMLHelper::_('uitab.endTab'); ?>
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
<input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?>
</form>
<?php // ---- Remote Destination Add/Edit Modal ---- ?>
<?php if ($profileId): ?>
<div class="modal fade" id="remoteModal" tabindex="-1" aria-labelledby="remoteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="remoteModalLabel"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php echo Text::_('JCLOSE'); ?>"></button>
</div>
<div class="modal-body">
<input type="hidden" id="remoteEditId" value="0">
<div class="mb-3">
<label for="remoteTitle" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></label>
<input type="text" class="form-control" id="remoteTitle" maxlength="255" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="remoteType" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></label>
<select class="form-select" id="remoteType">
<option value="sftp"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP'); ?></option>
<option value="s3"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3'); ?></option>
<option value="google_drive"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE'); ?></option>
</select>
</div>
<div class="col-md-3">
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ENABLED'); ?></label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="remoteEnabled" checked>
<label class="form-check-label" for="remoteEnabled"><?php echo Text::_('JYES'); ?></label>
</div>
</div>
<div class="col-md-3">
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL'); ?></label>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="remoteKeepLocal" checked>
<label class="form-check-label" for="remoteKeepLocal"><?php echo Text::_('JYES'); ?></label>
</div>
</div>
</div>
<hr>
<?php // SFTP fields ?>
<div id="remoteFields_sftp" class="remote-type-fields">
<div class="row mb-3">
<div class="col-md-8">
<label for="remoteCfg_sftp_host" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST'); ?></label>
<input type="text" class="form-control" id="remoteCfg_sftp_host" maxlength="255">
</div>
<div class="col-md-4">
<label for="remoteCfg_sftp_port" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT'); ?></label>
<input type="number" class="form-control" id="remoteCfg_sftp_port" value="22" min="1" max="65535">
</div>
</div>
<div class="mb-3">
<label for="remoteCfg_sftp_username" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME'); ?></label>
<input type="text" class="form-control" id="remoteCfg_sftp_username" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_sftp_auth_type" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE'); ?></label>
<select class="form-select" id="remoteCfg_sftp_auth_type">
<option value="key"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY'); ?></option>
<option value="password"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD'); ?></option>
<option value="key_passphrase"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE'); ?></option>
</select>
</div>
<div class="mb-3" id="remoteSftpPasswordWrap">
<label for="remoteCfg_sftp_password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD'); ?></label>
<input type="password" class="form-control" id="remoteCfg_sftp_password" maxlength="255">
</div>
<div class="mb-3" id="remoteSftpKeyWrap">
<label for="remoteCfg_sftp_key_data" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY'); ?></label>
<textarea class="form-control" id="remoteCfg_sftp_key_data" rows="4" placeholder="Paste SSH private key or leave as-is to keep existing"></textarea>
</div>
<div class="mb-3" id="remoteSftpPassphraseWrap">
<label for="remoteCfg_sftp_passphrase" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE'); ?></label>
<input type="password" class="form-control" id="remoteCfg_sftp_passphrase" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_sftp_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH'); ?></label>
<input type="text" class="form-control" id="remoteCfg_sftp_path" value="/backups" maxlength="512">
</div>
</div>
<?php // S3 fields ?>
<div id="remoteFields_s3" class="remote-type-fields" style="display:none;">
<div class="mb-3">
<label for="remoteCfg_s3_endpoint" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT'); ?></label>
<input type="text" class="form-control" id="remoteCfg_s3_endpoint" maxlength="512" placeholder="https://s3.amazonaws.com">
</div>
<div class="mb-3">
<label for="remoteCfg_s3_region" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_REGION'); ?></label>
<input type="text" class="form-control" id="remoteCfg_s3_region" value="us-east-1" maxlength="50">
</div>
<div class="mb-3">
<label for="remoteCfg_s3_access_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY'); ?></label>
<input type="text" class="form-control" id="remoteCfg_s3_access_key" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_s3_secret_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY'); ?></label>
<input type="password" class="form-control" id="remoteCfg_s3_secret_key" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_s3_bucket" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET'); ?></label>
<input type="text" class="form-control" id="remoteCfg_s3_bucket" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_s3_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_PATH'); ?></label>
<input type="text" class="form-control" id="remoteCfg_s3_path" value="/backups" maxlength="512">
</div>
</div>
<?php // Google Drive fields ?>
<div id="remoteFields_google_drive" class="remote-type-fields" style="display:none;">
<div class="mb-3">
<label for="remoteCfg_gdrive_client_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID'); ?></label>
<input type="text" class="form-control" id="remoteCfg_gdrive_client_id" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_gdrive_client_secret" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET'); ?></label>
<input type="password" class="form-control" id="remoteCfg_gdrive_client_secret" maxlength="255">
</div>
<div class="mb-3">
<label for="remoteCfg_gdrive_refresh_token" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN'); ?></label>
<input type="text" class="form-control" id="remoteCfg_gdrive_refresh_token" maxlength="512">
</div>
<div class="mb-3">
<label for="remoteCfg_gdrive_folder_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID'); ?></label>
<input type="text" class="form-control" id="remoteCfg_gdrive_folder_id" maxlength="255">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
<button type="button" class="btn btn-primary" id="btnSaveRemote">
<span class="icon-save" aria-hidden="true"></span>
<?php echo Text::_('JAPPLY'); ?>
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
'use strict';
const profileId = <?php echo $profileId; ?>;
const token = '<?php echo $token; ?>';
if (!profileId) return;
const baseUrl = 'index.php?option=com_mokosuitebackup&task=ajax.';
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'));
// Type badge colours
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
const typeLabel = {
sftp: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP', true); ?>',
s3: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3', true); ?>',
google_drive: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE', true); ?>'
};
// Config field mappings per type
const configFields = {
sftp: ['host','port','username','auth_type','password','key_data','passphrase','path'],
s3: ['endpoint','region','access_key','secret_key','bucket','path'],
google_drive: ['client_id','client_secret','refresh_token','folder_id']
};
// Prefix mapping for config field IDs
const fieldPrefix = {sftp: 'sftp_', s3: 's3_', google_drive: 'gdrive_'};
let remotesData = [];
// ---- Load remotes ----
function loadRemotes() {
loadingTr.style.display = '';
emptyMsg.style.display = 'none';
fetch(baseUrl + 'listRemotes&profile_id=' + profileId + '&' + token + '=1', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(r => r.json())
.then(data => {
loadingTr.style.display = 'none';
if (data.error) {
showTableMessage(data.message, 'text-danger');
return;
}
remotesData = data.items || [];
renderTable();
})
.catch(() => {
loadingTr.style.display = 'none';
showTableMessage('Failed to load remotes', 'text-danger');
});
}
function renderTable() {
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
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');
// Title cell
const tdTitle = document.createElement('td');
tdTitle.textContent = item.title;
tr.appendChild(tdTitle);
// Type badge cell
const tdType = document.createElement('td');
const badgeSpan = document.createElement('span');
badgeSpan.className = 'badge ' + (typeBadge[item.type] || 'bg-secondary');
badgeSpan.textContent = typeLabel[item.type] || item.type;
tdType.appendChild(badgeSpan);
tr.appendChild(tdType);
// Enabled toggle cell
const tdEnabled = document.createElement('td');
const toggleSpan = document.createElement('span');
toggleSpan.className = 'badge ' + (item.enabled ? 'bg-success' : 'bg-secondary');
toggleSpan.style.cursor = 'pointer';
toggleSpan.setAttribute('data-toggle-id', item.id);
toggleSpan.textContent = item.enabled ? 'Enabled' : 'Disabled';
tdEnabled.appendChild(toggleSpan);
tr.appendChild(tdEnabled);
// Actions cell
const tdActions = document.createElement('td');
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-sm btn-outline-primary me-1';
editBtn.setAttribute('data-edit-id', item.id);
editBtn.title = 'Edit';
const editIcon = document.createElement('span');
editIcon.className = 'icon-pencil';
editIcon.setAttribute('aria-hidden', 'true');
editBtn.appendChild(editIcon);
tdActions.appendChild(editBtn);
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-sm btn-outline-danger';
delBtn.setAttribute('data-delete-id', item.id);
delBtn.title = 'Delete';
const delIcon = document.createElement('span');
delIcon.className = 'icon-trash';
delIcon.setAttribute('aria-hidden', 'true');
delBtn.appendChild(delIcon);
tdActions.appendChild(delBtn);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
function showTableMessage(message, cssClass) {
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
const tr = document.createElement('tr');
const td = document.createElement('td');
td.setAttribute('colspan', '4');
td.className = cssClass || '';
td.textContent = message;
tr.appendChild(td);
tbody.appendChild(tr);
}
// ---- Toggle enabled ----
tbody.addEventListener('click', function(e) {
const toggle = e.target.closest('[data-toggle-id]');
if (toggle) {
const id = toggle.getAttribute('data-toggle-id');
const body = new URLSearchParams();
body.set(token, '1');
body.set('remote_id', id);
body.set('profile_id', profileId);
fetch(baseUrl + 'toggleRemote', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: body
})
.then(r => r.json())
.then(data => { if (!data.error) loadRemotes(); })
.catch(() => {});
return;
}
const editBtn = e.target.closest('[data-edit-id]');
if (editBtn) {
openEdit(parseInt(editBtn.getAttribute('data-edit-id'), 10));
return;
}
const delBtn = e.target.closest('[data-delete-id]');
if (delBtn) {
if (!confirm('<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM', true); ?>')) return;
const id = delBtn.getAttribute('data-delete-id');
const body = new URLSearchParams();
body.set(token, '1');
body.set('remote_id', id);
body.set('profile_id', profileId);
fetch(baseUrl + 'deleteRemote', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: body
})
.then(r => r.json())
.then(data => { if (!data.error) loadRemotes(); })
.catch(() => {});
}
});
// ---- Add button ----
document.getElementById('btnAddRemote').addEventListener('click', function() {
openEdit(0);
});
// ---- Open modal for add / edit ----
function openEdit(id) {
document.getElementById('remoteEditId').value = id;
document.getElementById('remoteTitle').value = '';
document.getElementById('remoteType').value = 'sftp';
document.getElementById('remoteEnabled').checked = true;
document.getElementById('remoteKeepLocal').checked = true;
// Clear all config fields
document.querySelectorAll('.remote-type-fields input, .remote-type-fields textarea, .remote-type-fields select').forEach(function(el) {
if (el.type === 'number') {
el.value = el.defaultValue || '';
} else if (el.tagName === 'SELECT') {
el.selectedIndex = 0;
} else {
el.value = '';
}
});
// Restore defaults
const portField = document.getElementById('remoteCfg_sftp_port');
if (portField) portField.value = '22';
const s3Region = document.getElementById('remoteCfg_s3_region');
if (s3Region) s3Region.value = 'us-east-1';
const sftpPath = document.getElementById('remoteCfg_sftp_path');
if (sftpPath) sftpPath.value = '/backups';
const s3Path = document.getElementById('remoteCfg_s3_path');
if (s3Path) s3Path.value = '/backups';
if (id) {
const item = remotesData.find(r => r.id === id);
if (item) {
document.getElementById('remoteTitle').value = item.title;
document.getElementById('remoteType').value = item.type;
document.getElementById('remoteEnabled').checked = !!item.enabled;
document.getElementById('remoteKeepLocal').checked = !!item.keep_local;
// Populate config fields
const prefix = fieldPrefix[item.type] || '';
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];
}
});
}
document.getElementById('remoteModalLabel').textContent =
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_EDIT', true); ?>';
} else {
document.getElementById('remoteModalLabel').textContent =
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD', true); ?>';
}
updateTypeFields();
modal.show();
}
// ---- Type selector toggles field visibility ----
document.getElementById('remoteType').addEventListener('change', updateTypeFields);
function updateTypeFields() {
const type = document.getElementById('remoteType').value;
document.querySelectorAll('.remote-type-fields').forEach(function(el) {
el.style.display = 'none';
});
const target = document.getElementById('remoteFields_' + type);
if (target) target.style.display = '';
// SFTP auth_type sub-fields
if (type === 'sftp') {
updateSftpAuthFields();
}
}
const sftpAuthType = document.getElementById('remoteCfg_sftp_auth_type');
if (sftpAuthType) {
sftpAuthType.addEventListener('change', updateSftpAuthFields);
}
function updateSftpAuthFields() {
const auth = document.getElementById('remoteCfg_sftp_auth_type').value;
document.getElementById('remoteSftpPasswordWrap').style.display = (auth === 'password') ? '' : 'none';
document.getElementById('remoteSftpKeyWrap').style.display = (auth === 'key' || auth === 'key_passphrase') ? '' : 'none';
document.getElementById('remoteSftpPassphraseWrap').style.display = (auth === 'key_passphrase') ? '' : 'none';
}
// ---- Save remote ----
document.getElementById('btnSaveRemote').addEventListener('click', function() {
const type = document.getElementById('remoteType').value;
const title = document.getElementById('remoteTitle').value.trim();
if (!title) {
document.getElementById('remoteTitle').focus();
return;
}
// Build config object from visible fields
const config = {};
const prefix = fieldPrefix[type] || '';
const fields = configFields[type] || [];
fields.forEach(function(f) {
const el = document.getElementById('remoteCfg_' + prefix + f);
if (el) {
config[f] = el.value;
}
});
const body = new URLSearchParams();
body.set(token, '1');
body.set('remote_id', document.getElementById('remoteEditId').value);
body.set('profile_id', profileId);
body.set('remote_title', title);
body.set('remote_type', type);
body.set('remote_enabled', document.getElementById('remoteEnabled').checked ? '1' : '0');
body.set('remote_keep_local', document.getElementById('remoteKeepLocal').checked ? '1' : '0');
body.set('remote_config', JSON.stringify(config));
document.getElementById('btnSaveRemote').disabled = true;
fetch(baseUrl + 'saveRemote', {
method: 'POST',
headers: {'X-Requested-With': 'XMLHttpRequest'},
body: body
})
.then(r => r.json())
.then(data => {
document.getElementById('btnSaveRemote').disabled = false;
if (data.error) {
alert(data.message || 'Save failed');
return;
}
modal.hide();
loadRemotes();
})
.catch(() => {
document.getElementById('btnSaveRemote').disabled = false;
alert('Network error');
});
});
// Initial load
loadRemotes();
});
</script>
<?php endif; ?>
@@ -0,0 +1,33 @@
; MokoSuiteBackup — CPanel Module language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED="MokoSuiteBackup is not installed or is disabled."
MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success"
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed"
MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet."
MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables"
MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED="Next Scheduled"
MOD_MOKOSUITEBACKUP_CPANEL_TOTAL="total"
MOD_MOKOSUITEBACKUP_CPANEL_STREAK="streak"
MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D="failed (7d)"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW="Backup Now"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS="Backup in Progress"
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE="Backup Complete"
MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE="Do not navigate away or close this window while the backup is running."
MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS="View Backups"
MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT="Create Snapshot"
MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES="View Profiles"
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS="Show Backup Now Buttons"
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE="Show Next Scheduled"
@@ -0,0 +1,8 @@
; MokoSuiteBackup — CPanel Module system language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
-->
<extension type="module" client="administrator" method="upgrade">
<name>mod_mokosuitebackup_cpanel</name>
<version>01.41.03</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION</description>
<namespace path="src">Joomla\Module\MokoSuiteBackupCpanel</namespace>
<files>
<folder>language</folder>
<folder>services</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.ini</language>
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="show_backup_buttons"
type="radio"
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="show_schedule"
type="radio"
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE"
default="1"
class="btn-group btn-group-yesno"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,26 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\Service\Provider\HelperFactory;
use Joomla\CMS\Extension\Service\Provider\Module;
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel'));
$container->registerServiceProvider(new HelperFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel\\Administrator\\Helper'));
$container->registerServiceProvider(new Module());
}
};
@@ -0,0 +1,72 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Module\MokoSuiteBackupCpanel\Administrator\Dispatcher;
defined('_JEXEC') or die;
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper;
class Dispatcher extends AbstractModuleDispatcher
{
/**
* Returns the layout data for the module template.
*
* @return array
*/
protected function getLayoutData(): array
{
$data = parent::getLayoutData();
$db = Factory::getContainer()->get('DatabaseDriver');
// Status summary from the shared helper
$status = BackupStatusHelper::getStatusSummary();
// Published profiles for "Backup Now" buttons
$profiles = [];
try {
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'backup_type']))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$profiles = $db->loadObjectList() ?: [];
} catch (\Throwable $e) {
// Component may not be installed yet
}
// Next scheduled backup
$nextScheduled = null;
try {
$query = $db->getQuery(true)
->select($db->quoteName(['t.next_execution', 't.title']))
->from($db->quoteName('#__scheduler_tasks', 't'))
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile'))
->where($db->quoteName('t.state') . ' = 1')
->order($db->quoteName('t.next_execution') . ' ASC');
$db->setQuery($query, 0, 1);
$nextScheduled = $db->loadObject() ?: null;
} catch (\Throwable $e) {
// Scheduler may not exist
}
$data['status'] = $status;
$data['profiles'] = $profiles;
$data['nextScheduled'] = $nextScheduled;
return $data;
}
}
@@ -0,0 +1,254 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage mod_mokosuitebackup_cpanel
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
/** @var array $displayData */
$status = $displayData['status'];
$profiles = $displayData['profiles'];
$nextScheduled = $displayData['nextScheduled'];
$params = $displayData['params'];
$showButtons = (int) $params->get('show_backup_buttons', 1);
$showSchedule = (int) $params->get('show_schedule', 1);
$latest = $status['latest'] ?? null;
$installed = $status['installed'] ?? false;
$totals = $status['totals'] ?? [];
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
$moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
?>
<?php if (!$installed) : ?>
<div class="alert alert-warning mb-0">
<span class="icon-warning-circle" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED'); ?>
</div>
<?php return; endif; ?>
<div id="<?php echo $moduleId; ?>" class="mod-mokosuitebackup-cpanel">
<!-- Last Backup Status -->
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-2">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP'); ?>
</h6>
<?php if ($latest) : ?>
<div class="d-flex align-items-center justify-content-between">
<div>
<span class="badge <?php echo $latest['status'] === 'complete' ? 'bg-success' : 'bg-danger'; ?>">
<?php echo $latest['status'] === 'complete'
? Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK')
: Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'); ?>
</span>
<span class="ms-1 small text-muted">
<?php echo htmlspecialchars($latest['profile'] ?? ''); ?>
</span>
</div>
<span class="small text-muted">
<?php echo HTMLHelper::_('date', $latest['backup_start'], Text::_('DATE_FORMAT_LC4')); ?>
</span>
</div>
<div class="small text-muted mt-1">
<?php echo HTMLHelper::_('number.bytes', (int) $latest['total_size']); ?>
&mdash; <?php echo Text::sprintf('MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES', (int) $latest['files_count'], (int) $latest['tables_count']); ?>
</div>
<?php else : ?>
<p class="text-muted small mb-0"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
<!-- Next Scheduled -->
<?php if ($showSchedule && $nextScheduled) : ?>
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-1">
<span class="icon-calendar" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED'); ?>
</h6>
<div class="small">
<?php echo HTMLHelper::_('date', $nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
<span class="text-muted">&mdash; <?php echo htmlspecialchars($nextScheduled->title); ?></span>
</div>
</div>
<?php endif; ?>
<!-- Stats row -->
<?php if (!empty($totals)) : ?>
<div class="d-flex gap-3 mb-3 small">
<div>
<span class="fw-bold"><?php echo (int) ($totals['all_time'] ?? 0); ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_TOTAL'); ?></span>
</div>
<div>
<span class="fw-bold text-success"><?php echo (int) ($totals['success_streak'] ?? 0); ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STREAK'); ?></span>
</div>
<?php if (($totals['recent_failed'] ?? 0) > 0) : ?>
<div>
<span class="fw-bold text-danger"><?php echo (int) $totals['recent_failed']; ?></span>
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D'); ?></span>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Backup Now Buttons -->
<?php if ($showButtons && !empty($profiles)) : ?>
<div class="mb-3">
<h6 class="text-muted text-uppercase small mb-2">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW'); ?>
</h6>
<div class="d-flex flex-wrap gap-1">
<?php foreach ($profiles as $profile) : ?>
<button type="button"
class="btn btn-sm btn-outline-primary msb-cpanel-backup-btn"
data-profile-id="<?php echo (int) $profile->id; ?>"
data-module-id="<?php echo $moduleId; ?>">
<?php echo htmlspecialchars($profile->title); ?>
<span class="badge bg-secondary ms-1"><?php echo htmlspecialchars($profile->backup_type); ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Quick Links -->
<div class="list-group list-group-flush small">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots&task=snapshot.add'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=profiles'); ?>"
class="list-group-item list-group-item-action px-0 py-1">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES'); ?>
</a>
</div>
<!-- Stepped Backup Modal -->
<div id="<?php echo $moduleId; ?>-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="<?php echo $moduleId; ?>-modal-title" style="margin:0 0 1rem;"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS'); ?></h3>
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE'); ?></strong>
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="<?php echo $moduleId; ?>-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="<?php echo $moduleId; ?>-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="<?php echo $moduleId; ?>-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
</div>
<script>
(function() {
var MOD_ID = <?php echo json_encode($moduleId); ?>;
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var TOKEN = <?php echo json_encode($ajaxToken); ?>;
var running = false;
window.addEventListener('beforeunload', function(e) {
if (running) { e.preventDefault(); e.returnValue = ''; }
});
function el(id) { return document.getElementById(id); }
function showModal() {
running = true;
el(MOD_ID + '-modal').style.display = 'block';
}
function hideModal() {
running = false;
el(MOD_ID + '-modal').style.display = 'none';
}
function updateProgress(pct, msg, phase) {
var bar = el(MOD_ID + '-progress-bar');
bar.style.width = pct + '%';
bar.textContent = pct + '%';
el(MOD_ID + '-status').textContent = msg;
el(MOD_ID + '-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
var form = new URLSearchParams();
form.append(TOKEN, '1');
for (var k in params) { form.append(k, params[k]); }
var res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startBackup(profileId) {
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
var initResult = await postAjax({ task: 'ajax.init', profile_id: profileId });
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
var sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({ task: 'ajax.step', session_id: sessionId });
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
el(MOD_ID + '-modal-title').textContent = <?php echo json_encode(Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE')); ?>;
setTimeout(function() { hideModal(); location.reload(); }, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('#' + MOD_ID + ' .msb-cpanel-backup-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
startBackup(this.getAttribute('data-profile-id'));
});
});
});
})();
</script>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+2 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.38.05</version>
<version>01.41.03</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -28,6 +28,7 @@
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
<file type="module" id="mod_mokosuitebackup_cpanel" client="administrator">mod_mokosuitebackup_cpanel.zip</file>
</files>
<languages>