Compare commits

...

86 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
gitea-actions[bot] aaf189b87a chore: promote changelog [Unreleased] → [01.38.05] 2026-06-23 16:59:10 +00:00
gitea-actions[bot] 61023821e6 chore(release): build 01.38.05 [skip ci]
Publish to Composer / Publish Package (release) Failing after 34s
2026-06-23 16:59:02 +00:00
jmiller 02a6e30db1 Merge pull request 'feat: Comprehensive help modal for backup directory + fix help button' (#128) from fix/folder-picker-tooltip into main 2026-06-23 16:58:45 +00:00
gitea-actions[bot] 5a0cd51df6 chore(version): pre-release bump to 01.38.05-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
2026-06-23 16:58:33 +00:00
Jonathan Miller 12c832d7fe feat: comprehensive help modal for backup directory field
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 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Universal: Build & Release / Promote to RC (pull_request) Failing after 8s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s
Expanded the help modal with:
- Absolute paths: starts with / or drive letter, used as-is
- Relative paths: ./backups, ../backups, ../../backups with table
  showing URL-style conventions and resolved examples
- Placeholder paths: detailed descriptions of each placeholder
  with current server values
- Recommended configurations table: single site, multi-site,
  date-organized, per-profile, shared hosting
- Security warnings for web-root-accessible paths
- Help button uses JS click handler with Bootstrap 5 fallback
  (fixes non-working tooltip icon)
2026-06-23 11:58:20 -05:00
gitea-actions[bot] 65c8820db4 chore: promote changelog [Unreleased] → [01.38.04] 2026-06-23 16:53:45 +00:00
gitea-actions[bot] 0f914c3061 chore(release): build 01.38.04 [skip ci]
Publish to Composer / Publish Package (release) Failing after 42s
2026-06-23 16:53:42 +00:00
jmiller 4191f44c1b Merge pull request 'feat: Uppercase all placeholders + EXAMPLE prefix in display' (#127) from fix/uppercase-placeholders into main 2026-06-23 16:53:28 +00:00
gitea-actions[bot] fb99afbeba chore(version): pre-release bump to 01.38.04-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Publish to Composer / Publish Package (release) Failing after 33s
2026-06-23 16:53:16 +00:00
Jonathan Miller de632e9c5c feat: uppercase all placeholders + EXAMPLE prefix in resolution display
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 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 20s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 10s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Build & Release / Promote to RC (pull_request) Failing after 10s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 47s
All placeholders changed from lowercase to UPPERCASE:
[host] → [HOST], [site_name] → [SITE_NAME], [date] → [DATE],
[datetime] → [DATETIME], [profile_id] → [PROFILE_ID], etc.

[HOME] and [DEFAULT_DIR] were already uppercase — now consistent.

SQL migration 01.39.01 updates existing profile data in the database.
Resolution display prefixed with "EXAMPLE:" to clarify these are
example values resolved at backup time.

13 files updated across engines, fields, forms, templates, and SQL.
2026-06-23 11:52:52 -05:00
gitea-actions[bot] 53ff99148c chore: promote changelog [Unreleased] → [01.38.03] 2026-06-23 16:50:30 +00:00
gitea-actions[bot] c2ff3b272a chore(release): build 01.38.03 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-23 16:50:27 +00:00
jmiller 747b68c179 Merge pull request 'fix: Resolve [site_name] and all placeholders in checkDir AJAX' (#126) from fix/site-name-resolution into main 2026-06-23 16:50:07 +00:00
gitea-actions[bot] cbff40d04c chore(version): pre-release bump to 01.38.03-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
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
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 21s
2026-06-23 16:49:45 +00:00
Jonathan Miller e415e701cd fix: resolve [site_name] and other placeholders in checkDir AJAX
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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Failing after 15s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
BackupDirectory::resolve() only handles [HOME] and [DEFAULT_DIR].
The checkDir AJAX endpoint now uses PlaceholderResolver to also
resolve [site_name], [host], [profile_id], [date], etc. before
checking if the directory exists. This makes the "Resolves to"
display accurate for all placeholder types.
2026-06-23 11:49:21 -05:00
jmiller d184ed9de0 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 16:38:26 +00:00
jmiller 297f27c807 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 16:38:26 +00:00
jmiller 30e8d7baa9 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 16:38:25 +00:00
jmiller efc5754bef chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 16:38:23 +00:00
gitea-actions[bot] e3e422d29e chore: promote changelog [Unreleased] → [01.38.02] 2026-06-23 16:37:12 +00:00
gitea-actions[bot] 9f5c8c0b5e chore(release): build 01.38.02 [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 16:37:04 +00:00
jmiller 044e57adf3 Merge pull request 'fix: Placeholder resolution display + CSRF token on Run Backup button' (#125) from fix/placeholder-resolution-display into main 2026-06-23 16:36:45 +00:00
gitea-actions[bot] e7f165ac96 chore(version): pre-release bump to 01.38.02-dev [skip ci]
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 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 13s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Publish to Composer / Publish Package (release) Failing after 45s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 48s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3m46s
2026-06-23 16:36:09 +00:00
Jonathan Miller fc41e1801a fix: placeholder resolution display + CSRF token on Run Backup button
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 26s
FolderPickerField: shows resolved placeholder values below input as
badges (e.g. [HOME]=/home/user, [host]=example.com), plus full
resolved path. Updates live as user types.

BackupsController::start(): accept CSRF token from both GET and POST
so the "Run Backup Now" link button on profile edit works without
triggering "security token did not match" error.
2026-06-23 11:35:48 -05:00
gitea-actions[bot] 1aa35dd041 chore: promote changelog [Unreleased] → [01.38.01] 2026-06-23 16:28:19 +00:00
gitea-actions[bot] 6a1f4a8797 chore(release): build 01.38.01 [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-23 16:28:13 +00:00
jmiller 6f6a6c705b Merge pull request 'fix: include_mokorestore column type — TINYINT cannot store 'standalone'' (#124) from fix/mokorestore-column-type into main 2026-06-23 16:27:48 +00:00
gitea-actions[bot] e8d7d1d421 chore(version): pre-release bump to 01.38.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 29s
2026-06-23 16:27:27 +00:00
Jonathan Miller cd31617e21 fix: change include_mokorestore column from TINYINT to VARCHAR(20)
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
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
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 7s
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 45s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 50s
The column was TINYINT(1) which can only store 0/1. The new
'standalone' mode value causes MySQL to truncate the string to 0,
breaking profile save. Changed to VARCHAR(20) to support all three
modes: '0' (none), '1' (wrapped), 'standalone'.
2026-06-23 11:27:04 -05:00
jmiller 6d9d96d7cd chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 16:23:22 +00:00
jmiller df7c07bec4 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 16:23:21 +00:00
jmiller 5b4717bf6f chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 16:23:20 +00:00
jmiller 65d30613b2 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 16:23:18 +00:00
gitea-actions[bot] d5bbab7e72 chore: promote changelog [Unreleased] → [01.38.00] 2026-06-23 16:22:00 +00:00
gitea-actions[bot] 18b65d30ac chore(release): build 01.38.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 34s
2026-06-23 16:21:52 +00:00
jmiller f55b032cc9 Merge pull request 'feat: Standalone restore script — separate file that scans for ZIPs (#107)' (#123) from feat/standalone-restore-script into main 2026-06-23 16:21:33 +00:00
Jonathan Miller e62dba8f40 feat: standalone restore script — separate file that scans for ZIPs (#107)
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 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
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: Extension CI / Lint & Validate (pull_request) Failing after 53s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8m41s
New MokoRestore mode: 'standalone' generates restore.php as a separate
file that scans its directory for ZIP backup archives and lets the user
choose which one to restore. Unlike 'wrapped' mode which bundles
restore.php inside the backup ZIP, standalone mode keeps both files
separate — ideal for remote servers where you SCP the backup.

Changes:
- MokoRestore::generateStandalone() — writes restore.php with ZIP scanner
- Profile form: include_mokorestore now a dropdown (none/wrapped/standalone)
- BackupEngine: standalone mode writes restore.php + uploads to remote
- Restore script uses safe DOM methods (no innerHTML with user data)

Closes #107
2026-06-23 11:20:23 -05:00
jmiller 0619825f38 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 16:05:30 +00:00
60 changed files with 6054 additions and 1777 deletions
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release # INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml # PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00 # VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) # BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump" name: "Universal: Auto Version Bump"
on: on:
push: push:
branches: branches:
- dev - dev
- rc - rc
- 'feature/**' - 'feature/**'
- 'patch/**' - 'patch/**'
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions: permissions:
contents: write contents: write
jobs: jobs:
bump: bump:
name: Version Bump name: Version Bump
runs-on: release runs-on: release
if: >- if: >-
!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') && !contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request') !startsWith(github.event.head_commit.message, 'Merge pull request')
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup mokocli tools - name: Setup mokocli tools
run: | run: |
if ! command -v composer &> /dev/null; then if ! command -v composer &> /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 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 fi
if [ -d "/opt/mokocli/cli" ]; then if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else else
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \ "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV" echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi fi
- name: Bump version - name: Bump version
run: | run: |
php ${MOKO_CLI}/version_auto_bump.php \ php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \ --path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
-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 # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation # INGROUP: mokocli.Automation
# VERSION: 01.37.00 # VERSION: 01.41.03
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-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
+120 -16
View File
@@ -1,22 +1,126 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
## [01.37.00] --- 2026-06-23 ## [01.41.00] 2026-06-23
## [01.37.00] --- 2026-06-23 ### Added — Multi-Remote Storage
- New `#__mokosuitebackup_remotes` table for multiple destinations per profile
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit
- Engine uploads to ALL enabled destinations (BackupEngine + SteppedBackupEngine)
- Migration auto-converts existing SFTP/S3/GDrive/FTP profile columns to new table
- Backward compatibility: falls back to legacy single-remote columns if table empty
- Secrets masked in API responses, merged from DB on save
### Added — Content Snapshots
- Lightweight JSON snapshots of articles, categories, and modules
- Includes tags, custom fields, workflow associations, field values
- Restore modes: Replace (clean slate), Merge (upsert), Selective (per-article)
- Snapshot retention: max count + max age with automatic cleanup
- Scheduled snapshot task via com_scheduler
- CLI: `mokosuitebackup:snapshot create|restore|list|delete`
- REST API: create, list, restore, delete, download snapshots
- Tabbed browse modal: Articles / Categories / Modules with item counts
### Added — SFTP Remote Storage
- SFTP support with SSH key file authentication (key stored base64 in database)
- Auth type dropdown: Password / Key File / Key File + Passphrase
- SshKeyField: file upload via FileReader, key never exposed in HTML
- SFTP remote directory browser for path selection
- `__KEEP_EXISTING__` sentinel preserves key on profile re-save
### Added — MokoRestore Wizard (9 steps)
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
- Preset buttons: "All Replace", "All Skip", "Everything except users"
- Post-restore actions: reset passwords, hits, versions, sessions, cache
- Auto-detect sanitized passwords and prompt for reset (random temp password)
- Standalone mode: restore.php scans directory for ZIP files
- Wrapped mode: restore.php bundled inside backup ZIP
- Security gate with filesystem verification + path traversal protection
### Added — Data Sanitization
- Sanitize user passwords: replace hashes with invalid sentinel
- Sanitize user emails: replace with dummy values
- Clear session data: exclude `#__session` table
- Preserve super admin credentials (optional)
- GDPR-friendly backup sharing for demos and staging sites
### Added — Backup Engine
- Pre-flight validation: directory, disk space, extensions, credentials, running backups
- Auto-verify archive integrity after creation (ZIP, tar.gz, 7z)
- 7z archive format via system 7za/7z CLI binary with native encryption
- Streaming database dump to temp file (prevents OOM on large sites)
- S3 streaming upload via CURLOPT_PUT (prevents OOM)
- Graceful remote degradation: local backup preserved if upload fails
- DatabaseDumper::dumpToFile() for memory-efficient operation
### Added — Admin UI
- Dashboard: snapshot widget, 30-day backup trend chart, per-profile storage breakdown
- CPanel admin dashboard module (mod_mokosuitebackup_cpanel) with quick actions
- Backup type filter dropdown in backups list
- Backup comparison: select two backups for side-by-side diff
- Archive browser: view files inside backup without extracting
- Manual purge: delete backups older than a date with count preview
- 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 ### Added
- Run Backup button on profiles list and edit views with backup count badges (#100, #101) - Initial release: full-site backup and restore for Joomla 6
- Snapshot detail view with tabbed browser for articles, categories, and modules (#104) - Database, files, and configuration backup
- "Do not navigate away" warning in backup and restore progress modals (#108) - ZIP and tar.gz archive formats with AES-256 encryption
- Joomla Action Logs integration for restore, snapshot, and snapshot restore events (#110) - Differential backups based on file manifests
- 8 comprehensive testing issues created (#111-#118) - FTP/FTPS, S3, Google Drive remote storage
- Manual purge feature issue (#119) - MokoRestore standalone restore wizard
- CLI backup and restore commands
## [01.36.00] --- 2026-06-23 - REST API for remote management
- Scheduled tasks via com_scheduler
## [01.36.00] --- 2026-06-23 - Email and ntfy push notifications
- Per-profile retention, exclusions, and notifications
## [01.35.04] --- 2026-06-23 - Akeeba Backup migration tool
- Admin dashboard with system health checks
## [01.35.04] --- 2026-06-23
+64 -34
View File
@@ -1,50 +1,80 @@
# MokoSuiteBackup # MokoSuiteBackup
<!-- VERSION: 01.37.00 -->
Full-site backup and restore for Joomla — database, files, and configuration. Full-site backup and restore for Joomla — database, files, and configuration.
## Overview | Field | Value |
|---|---|
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. | **Package** | `pkg_mokosuitebackup` |
| **Type** | Joomla Package (8 sub-extensions) |
| **Joomla** | 6.x+ |
| **PHP** | 8.1+ |
| **License** | GPL-3.0-or-later |
## Features ## Features
- Full site backup (database + files + configuration) ### Backup
- Database-only backup mode - Full site, database-only, files-only, and differential backup modes
- Files-only backup mode - Pre-flight validation — checks directory, disk space, extensions, credentials before starting
- Multiple backup profiles with independent configurations - Auto-verify archive integrity after creation
- File and directory exclusion filters - Stepped AJAX engine prevents timeout on shared hosting
- Table exclusion filters for database backups - AES-256 ZIP encryption with configurable password
- Step-based backup engine (avoids PHP timeout on large sites) - Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
- CLI script for cron/scheduled backups - Data sanitization — optionally clear user passwords, emails, and sessions in backup
- REST API (Joomla Web Services) for remote management
- Backup record management (list, download, delete) ### Content Snapshots
- Automatic old backup cleanup (configurable retention) - Lightweight JSON snapshots of articles, categories, and modules
- Admin dashboard with backup history and storage usage - 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 ## 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 2. Joomla Administrator > Extensions > Install
3. System plugin enabled automatically on install 3. Components > MokoSuiteBackup > Dashboard
## Configuration ## Documentation
- **Component**: Administrator > Components > MokoSuiteBackup See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
- **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
## License ## 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.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" /> <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.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> </section>
</access> </access>
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
*/ */
public function displayList(): static 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); $this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]); echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close(); $this->app->close();
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
*/ */
public function download(): static 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); $this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]); echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close(); $this->app->close();
@@ -39,6 +39,73 @@
</field> </field>
</fieldset> </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"> <fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
<field <field
name="webcron_secret" name="webcron_secret"
@@ -172,6 +239,32 @@
</field> </field>
</fieldset> </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" <fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC"> description="JCONFIG_PERMISSIONS_DESC">
<field <field
@@ -40,6 +40,7 @@
> >
<option value="zip">ZIP</option> <option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option> <option value="tar.gz">tar.gz</option>
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
</field> </field>
<field <field
name="compression_level" name="compression_level"
@@ -75,22 +76,22 @@
type="PlaceholderText" type="PlaceholderText"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT" label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC" description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]" default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
maxlength="512" maxlength="512"
hint="[host]_[datetime]_profile[profile_id]" hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
placeholders="[host],[datetime],[date],[time],[year],[month],[day],[hour],[minute],[second],[profile_id],[profile_name],[site_name],[type],[random]" placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field" addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/> />
<field <field
name="include_mokorestore" name="include_mokorestore"
type="radio" type="list"
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE" label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC" description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
default="0" default="0"
class="btn-group"
> >
<option value="1">JYES</option> <option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
<option value="0">JNO</option> <option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
</field> </field>
<field <field
name="encryption_password" name="encryption_password"
@@ -101,6 +102,54 @@
/> />
</fieldset> </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"> <fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
<field <field
name="id" name="id"
@@ -153,6 +202,13 @@
</fieldset> </fieldset>
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE"> <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 <field
name="remote_storage" name="remote_storage"
type="list" type="list"
@@ -243,12 +299,13 @@
/> />
<field <field
name="sftp_path" name="sftp_path"
type="text" type="SftpPath"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH" label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC" description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups" default="/backups"
maxlength="512" maxlength="512"
showon="remote_storage:sftp" showon="remote_storage:sftp"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/> />
</fieldset> </fieldset>
@@ -119,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
; Archive settings ; Archive settings
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format" COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file" 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="Compression Level"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower" COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)" COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
@@ -126,15 +127,29 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)"
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)" COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)" COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password" 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="Split Size (MB)"
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting." 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="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="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="Include Restore Script" COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed." 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 ; Exclusion filter fields
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories" COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
@@ -262,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="SSH Password"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file." 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="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_UPLOAD="Upload Key File"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key" COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded" COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
@@ -399,6 +414,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted." COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s" 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 ; Snapshot ACL
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots" 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." 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."
@@ -435,6 +482,28 @@ COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected" COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore." 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 ; Errors
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." 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." 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_REMOTE="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" 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." 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"> <extension type="component" method="upgrade">
<name>MokoSuiteBackup</name> <name>MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max', `compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part', `split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]', `backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders', `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
@@ -39,7 +39,11 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `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)', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive', `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_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_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
@@ -103,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
KEY `idx_created` (`created`) KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 default backup profile (IGNORE prevents duplicate key error on update)
INSERT IGNORE INTO `#__mokosuitebackup_profiles` ( INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
`id`, `title`, `description`, `backup_type`, `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_records`;
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`; DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`; ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
-- Add archive_name_format column with placeholder support -- Add archive_name_format column with placeholder support
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`; ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
@@ -0,0 +1,5 @@
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
-- Needed to support 'standalone' value alongside 0/1
ALTER TABLE `#__mokosuitebackup_profiles`
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
@@ -0,0 +1,34 @@
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
UPDATE `#__mokosuitebackup_profiles` SET
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
`archive_name_format`,
'[host]', '[HOST]'),
'[site_name]', '[SITE_NAME]'),
'[datetime]', '[DATETIME]'),
'[date]', '[DATE]'),
'[time]', '[TIME]'),
'[year]', '[YEAR]'),
'[month]', '[MONTH]'),
'[day]', '[DAY]'),
'[hour]', '[HOUR]'),
'[minute]', '[MINUTE]'),
'[second]', '[SECOND]'),
'[profile_id]', '[PROFILE_ID]'),
'[profile_name]', '[PROFILE_NAME]'),
'[type]', '[TYPE]'),
'[random]', '[RANDOM]')
WHERE `archive_name_format` REGEXP '\\[[a-z]';
UPDATE `#__mokosuitebackup_profiles` SET
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
`backup_dir`,
'[host]', '[HOST]'),
'[site_name]', '[SITE_NAME]'),
'[date]', '[DATE]'),
'[year]', '[YEAR]'),
'[month]', '[MONTH]'),
'[day]', '[DAY]'),
'[profile_id]', '[PROFILE_ID]'),
'[profile_name]', '[PROFILE_NAME]')
WHERE `backup_dir` REGEXP '\\[[a-z]';
@@ -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` != '';
@@ -15,8 +15,10 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session; use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
@@ -283,7 +285,32 @@ class AjaxController extends BaseController
return; return;
} }
$resolved = BackupDirectory::resolve($rawPath); /* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
$profileId = $this->input->getInt('profile_id', 0);
if ($profileId > 0) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
}
if (empty($profile)) {
/* No profile context — create a minimal dummy for PlaceholderResolver */
$profile = (object) [
'id' => 1,
'title' => 'default',
'backup_type' => 'full',
];
}
$resolver = new PlaceholderResolver($profile);
$withNamePlaceholders = $resolver->resolve($rawPath);
$resolved = BackupDirectory::resolve($withNamePlaceholders);
if (BackupDirectory::hasPlaceholders($resolved)) { if (BackupDirectory::hasPlaceholders($resolved)) {
$this->sendJson([ $this->sendJson([
@@ -321,7 +348,7 @@ class AjaxController extends BaseController
return; 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); $this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return; return;
@@ -357,7 +384,7 @@ class AjaxController extends BaseController
return; 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); $this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return; return;
@@ -389,7 +416,7 @@ class AjaxController extends BaseController
return; 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); $this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return; return;
@@ -686,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. * Compare two backup records side-by-side.
* POST: task=ajax.compareBackups&id1=123&id2=456 * POST: task=ajax.compareBackups&id1=123&id2=456
@@ -698,7 +776,7 @@ class AjaxController extends BaseController
return; 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); $this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return; return;
@@ -801,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. * Send a JSON response and close the application.
*/ */
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route; use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
*/ */
public function start(): void public function start(): void
{ {
$this->checkToken(); /* Accept token from both GET (profile Run button) and POST (backup form).
Joomla's checkToken() throws on failure, so try GET first. */
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) { if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
@@ -157,6 +165,88 @@ class BackupsController extends AdminController
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false)); $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
} }
/**
* 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. * Verify integrity of a backup archive by re-computing SHA-256.
*/ */
@@ -259,7 +259,7 @@ class SnapshotsController extends AdminController
{ {
$this->checkToken(); $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->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
@@ -87,8 +87,14 @@ class BackupEngine
$archiveFormat = $profile->archive_format ?? 'zip'; $archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = ''; $archiveName = '';
$archiver = $this->createArchiver($archiveFormat); $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(); $archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt; $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) { if (empty($description)) {
@@ -137,7 +143,19 @@ class BackupEngine
if ($profile->backup_type !== 'files') { if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...'); $this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql'; $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); $dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql'); $archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount(); $tablesCount = $dumper->getTablesCount();
@@ -216,12 +234,14 @@ class BackupEngine
$encryptionPassword = $profile->encryption_password ?? ''; $encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) { if (!empty($encryptionPassword)) {
if ($archiveFormat !== 'zip') { if ($archiveFormat === 'zip') {
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
} else {
$this->log('Encrypting archive with AES-256...'); $this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword); $this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted'); $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');
} }
} }
@@ -237,66 +257,118 @@ class BackupEngine
$this->verifyArchive($archivePath, $profile->backup_type); $this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified'); $this->log('Archive integrity verified');
// Step 2.5: Wrap with MokoRestore script (if enabled) // Step 2.5: MokoRestore script (if enabled)
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $mokoRestoreMode = $profile->include_mokorestore ?? '0';
$restoreScriptPath = '';
if ($includeMokoRestore) { if ($mokoRestoreMode === '1') {
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
$this->log('Wrapping with MokoRestore script...'); $this->log('Wrapping with MokoRestore script...');
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
MokoRestore::wrap($archivePath, $mokoRestorePath); MokoRestore::wrap($archivePath, $mokoRestorePath);
// Replace the original archive with the wrapped one
if (is_file($archivePath) && !unlink($archivePath)) { if (is_file($archivePath) && !unlink($archivePath)) {
$this->log('WARNING: Could not remove pre-wrap archive'); $this->log('WARNING: Could not remove pre-wrap archive');
} }
rename($mokoRestorePath, $archivePath); rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath); $totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
// Recompute checksum for the final wrapped archive
$checksum = hash_file('sha256', $archivePath); $checksum = hash_file('sha256', $archivePath);
$this->log('MokoRestore archive created: ' . $sizeHuman); $this->log('MokoRestore archive created: ' . $sizeHuman);
$this->log('SHA-256 (wrapped): ' . $checksum); $this->log('SHA-256 (wrapped): ' . $checksum);
} elseif ($mokoRestoreMode === 'standalone') {
// Standalone mode: restore.php as a separate file next to the backup ZIP
$this->log('Generating standalone restore.php...');
$restoreScriptPath = $this->backupDir . '/restore.php';
MokoRestore::generateStandalone($restoreScriptPath);
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
} }
$remoteFilename = ''; $remoteFilename = '';
$uploadFailed = false; $uploadFailed = false;
// Step 3: Remote upload (if configured) /* Step 3: Remote upload — iterate all enabled destinations */
// Wrapped in its own try-catch so a remote failure does not mark $remotes = $this->loadRemoteDestinations($db, $profileId);
// the entire backup as failed — the local archive is preserved.
$remoteStorage = $profile->remote_storage ?? 'none';
if ($remoteStorage !== 'none') { if (!empty($remotes)) {
try { foreach ($remotes as $remote) {
$this->log('Starting remote upload (' . $remoteStorage . ')...'); try {
$uploader = $this->createUploader($remoteStorage, $profile); $this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
$uploadResult = $uploader->upload($archivePath, $archiveName); $params = json_decode($remote->params, true) ?: [];
$uploader = $this->createUploaderFromParams($remote->type, $params);
$result = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) { if ($result['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName; $remoteFilename = $result['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']); $this->log(' Upload complete: ' . $result['message']);
// Delete local copy if configured /* Upload standalone restore.php if in standalone mode */
if (empty($profile->remote_keep_local) && is_file($archivePath)) { if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
@unlink($archivePath); $uploader->upload($restoreScriptPath, 'restore.php');
$this->log('Local copy removed (remote_keep_local = off)'); }
} else {
$uploadFailed = true;
$this->log(' WARNING: Upload failed: ' . $result['message']);
} }
} else { } catch (\Throwable $e) {
$uploadFailed = true; $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.'); $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 // Write log file alongside the archive
$logContent = implode("\n", $this->log); $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) { if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath); error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
} }
@@ -442,12 +514,15 @@ class BackupEngine
return match ($format) { return match ($format) {
'zip' => new ZipArchiver(), 'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(), 'tar.gz' => new TarGzArchiver(),
'7z' => new SevenZipArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format), default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
}; };
} }
/** /**
* Create the appropriate remote uploader based on the storage type. * 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 private function createUploader(string $type, object $profile): RemoteUploaderInterface
{ {
@@ -460,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. * Load the file manifest from the most recent full backup for this profile.
* Used by differential backups to determine which files changed. * Used by differential backups to determine which files changed.
@@ -547,6 +675,13 @@ class BackupEngine
return; return;
} }
// 7z verification via CLI
if ($extension === '7z') {
$this->verify7zArchive($archivePath);
return;
}
// ZIP verification // ZIP verification
$zip = new \ZipArchive(); $zip = new \ZipArchive();
@@ -608,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. * Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/ */
@@ -27,12 +27,35 @@ class DatabaseDumper
private int $tablesCount = 0; 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). * @param array $excludeTables Table names to exclude (with #__ prefix).
* Supports suffixes: :data-only, :structure-only. * @param bool $sanitizePasswords Replace user password hashes with invalid value
* No suffix = exclude both (backward compatible). * @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) { foreach ($excludeTables as $entry) {
if (str_ends_with($entry, ':data-only')) { if (str_ends_with($entry, ':data-only')) {
@@ -43,6 +66,16 @@ class DatabaseDumper
$this->excludeBoth[] = $entry; $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) { foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = []; $values = [];
foreach ($row as $value) { foreach ($row as $value) {
@@ -326,6 +360,7 @@ class DatabaseDumper
} }
foreach ($rows as $row) { foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = []; $values = [];
foreach ($row as $value) { foreach ($row as $value) {
@@ -351,6 +386,86 @@ class DatabaseDumper
return filesize($filePath) ?: 0; 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 public function getTablesCount(): int
{ {
return $this->tablesCount; return $this->tablesCount;
@@ -54,6 +54,191 @@ class MokoRestore
return $outputPath; return $outputPath;
} }
/**
* Generate the standalone restore.php script as a separate file.
*
* Unlike the wrapped version, this script scans its own directory
* for ZIP files and lets the user choose which one to restore from.
*
* @param string $outputPath Where to write restore.php
*
* @return string Path to the generated script
*/
public static function generateStandalone(string $outputPath): string
{
$script = self::generateStandaloneScript();
if (file_put_contents($outputPath, $script) === false) {
throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath);
}
return $outputPath;
}
/**
* Generate the standalone script content that scans for ZIPs.
*/
private static function generateStandaloneScript(): string
{
/* Take the normal backend but replace the hardcoded BACKUP_FILE
with a directory scanner that finds ZIP files */
$php = self::generateBackend();
/* Replace the fixed BACKUP_FILE constant with dynamic scanner */
$php = str_replace(
"define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');",
"/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" .
"define('BACKUP_FILE', ''); /* placeholder — overridden per request */",
$php
);
/* Inject the backup scanner function after the constants */
$scannerCode = <<<'SCANNER'
/**
* Scan the restore directory for ZIP files that look like backups.
*/
function scanForBackups(): array
{
$dir = RESTORE_DIR;
$files = [];
foreach (glob($dir . '/*.zip') as $path) {
$name = basename($path);
/* Skip the restore script wrapper if present */
if ($name === 'restore.php') {
continue;
}
$files[] = [
'name' => $name,
'path' => $path,
'size' => filesize($path),
'date' => date('Y-m-d H:i:s', filemtime($path)),
];
}
/* Sort by modification time, newest first */
usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $files;
}
/**
* Handle backup file selection and set the working file.
*/
function getSelectedBackupFile(): string
{
if (!empty($_POST['backup_file'])) {
$selected = basename($_POST['backup_file']); /* sanitize — basename only */
$path = RESTORE_DIR . '/' . $selected;
if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) {
return $path;
}
}
/* Auto-select if only one ZIP exists */
$backups = scanForBackups();
if (count($backups) === 1) {
return $backups[0]['path'];
}
return '';
}
SCANNER;
/* Insert scanner after the opening PHP section but before the action handlers */
$php = str_replace(
"/* ── Action Handlers",
$scannerCode . "\n/* ── Action Handlers",
$php
);
/* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */
$php = str_replace(
'$zip->open(BACKUP_FILE)',
'$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)',
$php
);
/* Modify the pre-checks to use getSelectedBackupFile() */
$php = str_replace(
"file_exists(BACKUP_FILE)",
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
$php
);
$html = self::generateFrontend();
/* Add backup file selector to the frontend before the extract step */
$selectorHtml = <<<'SELECTOR'
<!-- Backup File Selector (standalone mode) -->
<div id="mr-step-select" class="mr-step" style="display:none;">
<h2 class="mr-step-title">Select Backup File</h2>
<p class="mr-desc">Choose which backup archive to restore from.</p>
<div id="mr-backup-list"></div>
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
</div>
<script>
(function() {
var backups = <?php echo json_encode(scanForBackups()); ?>;
var list = document.getElementById('mr-backup-list');
var hiddenInput = document.getElementById('mr-backup-file');
if (backups.length === 0) {
var alert = document.createElement('div');
alert.className = 'mr-alert mr-alert-danger';
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
list.appendChild(alert);
} else if (backups.length === 1) {
hiddenInput.value = backups[0].name;
var found = document.createElement('div');
found.className = 'mr-alert mr-alert-success';
var strong = document.createElement('strong');
strong.textContent = backups[0].name;
found.appendChild(document.createTextNode('Found: '));
found.appendChild(strong);
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
list.appendChild(found);
} else {
var group = document.createElement('div');
group.className = 'mr-field-group';
backups.forEach(function(b) {
var label = document.createElement('label');
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
var radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'backup_choice';
radio.value = b.name;
radio.style.marginRight = '8px';
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
label.appendChild(radio);
var nameStrong = document.createElement('strong');
nameStrong.textContent = b.name;
label.appendChild(nameStrong);
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
group.appendChild(label);
});
list.appendChild(group);
}
})();
</script>
SELECTOR;
/* Insert the selector before the extract step in the HTML */
$html = str_replace(
'<!-- Step: Extract -->',
$selectorHtml . "\n<!-- Step: Extract -->",
$html
);
return $php . $html;
}
/** /**
* Generate the standalone restore.php script. * Generate the standalone restore.php script.
* *
@@ -191,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
function handleAction(string $action, array $data): array function handleAction(string $action, array $data): array
{ {
return match ($action) { return match ($action) {
'preflight' => actionPreflight(), 'preflight' => actionPreflight(),
'extract' => actionExtract($data), 'extract' => actionExtract($data),
'testdb' => actionTestDb($data), 'scanTables' => actionScanTables(),
'database' => actionDatabase($data), 'testdb' => actionTestDb($data),
'config' => actionConfig($data), 'database' => actionDatabase($data),
'listAdmins' => actionListAdmins($data), 'config' => actionConfig($data),
'resetAdmin' => actionResetAdmin($data), 'listAdmins' => actionListAdmins($data),
'provision' => actionProvision($data), 'resetAdmin' => actionResetAdmin($data),
'cleanup' => actionCleanup(), 'postRestore' => actionPostRestore($data),
default => ['success' => false, 'message' => 'Unknown action: ' . $action], 'detectSanitized' => detectSanitizedPasswords($data),
'provision' => actionProvision($data),
'cleanup' => actionCleanup(),
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
}; };
} }
@@ -366,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 function actionTestDb(array $data): array
{ {
$host = $data['db_host'] ?? 'localhost'; $host = $data['db_host'] ?? 'localhost';
@@ -423,10 +670,27 @@ function actionDatabase(array $data): array
// Replace abstract #__ prefix with the user's target prefix // Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql); $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); $parts = explode(";\n", $sql);
$statements = 0; $statements = 0;
$errors = 0; $errors = 0;
$errorList = []; $errorList = [];
$skipped = 0;
foreach ($parts as $part) { foreach ($parts as $part) {
$part = trim($part); $part = trim($part);
@@ -435,6 +699,42 @@ function actionDatabase(array $data): array
continue; 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 { try {
$pdo->exec($part); $pdo->exec($part);
$statements++; $statements++;
@@ -449,11 +749,22 @@ function actionDatabase(array $data): array
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); $pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
$msg = "Executed {$statements} statements";
if ($skipped > 0) {
$msg .= " ({$skipped} skipped)";
}
if ($errors > 0) {
$msg .= " ({$errors} warnings)";
}
return [ return [
'success' => ($statements > 0 || $errors === 0), 'success' => ($statements > 0 || $errors === 0),
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''), 'message' => $msg,
'statements' => $statements, 'statements' => $statements,
'errors' => $errors, 'errors' => $errors,
'skipped' => $skipped,
'errorList' => $errorList, 'errorList' => $errorList,
]; ];
} }
@@ -804,6 +1115,128 @@ function actionResetAdmin(array $data): array
return ['success' => true, 'message' => 'Admin password updated successfully']; 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 function actionProvision(array $data): array
{ {
$pdo = getDbConnection($data); $pdo = getDbConnection($data);
@@ -1048,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-steps" id="stepBar"> <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 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="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="3"><span class="mr-num">3</span>Tables</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</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>Admin</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>Provisioning</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>Complete</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> </div>
<!-- Step 0: Security Verification --> <!-- Step 0: Security Verification -->
@@ -1107,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div> </div>
</div> </div>
<!-- Step 3: Database --> <!-- Step 3: Table Conflict Resolution -->
<div class="mr-panel" id="panel3"> <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> <h2>Database Configuration</h2>
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p> <p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
<div class="mr-row"> <div class="mr-row">
@@ -1127,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-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
<div class="mr-status" id="dbStatus"></div> <div class="mr-status" id="dbStatus"></div>
<div class="mr-actions"> <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> <button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div> </div>
</div> </div>
<!-- Step 4: Site Configuration --> <!-- Step 5: Site Configuration -->
<div class="mr-panel" id="panel4"> <div class="mr-panel" id="panel5">
<h2>Site Configuration</h2> <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> <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>
@@ -1176,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div> </div>
<div class="mr-status" id="configStatus"></div> <div class="mr-status" id="configStatus"></div>
<div class="mr-actions"> <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> <button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div> </div>
</div> </div>
<!-- Step 5: Admin Password Reset --> <!-- Step 6: Admin Password Reset -->
<div class="mr-panel" id="panel5"> <div class="mr-panel" id="panel6">
<h2>Super Admin Password</h2> <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> <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"> <div class="mr-field">
@@ -1195,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div> </div>
<div class="mr-status" id="adminStatus"></div> <div class="mr-status" id="adminStatus"></div>
<div class="mr-actions"> <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> <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> <button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Step 6: Client Provisioning --> <!-- Step 7: Post-Restore Actions -->
<div class="mr-panel" id="panel6"> <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> <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> <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"> <ul class="mr-provision-list">
@@ -1219,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</ul> </ul>
<div class="mr-status" id="provisionStatus"></div> <div class="mr-status" id="provisionStatus"></div>
<div class="mr-actions"> <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> <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> <button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Step 7: Complete --> <!-- Step 9: Complete -->
<div class="mr-panel" id="panel7"> <div class="mr-panel" id="panel9">
<h2>Installation Complete</h2> <h2>Installation Complete</h2>
<p class="mr-desc">Your Joomla site has been restored and configured.</p> <p class="mr-desc">Your Joomla site has been restored and configured.</p>
<div class="mr-alert mr-alert-success"> <div class="mr-alert mr-alert-success">
@@ -1299,7 +1786,9 @@ function goStep(n) {
else if (sn < n) s.classList.add('done'); 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) { function setStatus(id, msg, type) {
@@ -1436,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() { function getDbParams() {
return { return {
db_host: document.getElementById('dbHost').value, db_host: document.getElementById('dbHost').value,
@@ -1462,7 +2055,12 @@ async function runDatabase() {
log('Importing database...'); log('Importing database...');
dbConfig = getDbParams(); 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%'; document.getElementById('dbProgress').style.width = '100%';
setBtnLoading(btn, false); setBtnLoading(btn, false);
@@ -1470,17 +2068,20 @@ async function runDatabase() {
if (r.success) { if (r.success) {
setStatus('dbStatus', r.message, 'success'); setStatus('dbStatus', r.message, 'success');
log(r.message); 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) { if (r.errorList && r.errorList.length > 0) {
r.errorList.forEach(function(e) { log(' Warning: ' + e); }); r.errorList.forEach(function(e) { log(' Warning: ' + e); });
} }
setTimeout(function() { goStep(4); }, 500); setTimeout(function() { goStep(5); }, 500);
} else { } else {
setStatus('dbStatus', r.message, 'error'); setStatus('dbStatus', r.message, 'error');
log('FAILED: ' + r.message); log('FAILED: ' + r.message);
} }
} }
// Step 4 // Step 5
async function runConfig() { async function runConfig() {
const btn = document.getElementById('btnConfig'); const btn = document.getElementById('btnConfig');
setBtnLoading(btn, true); setBtnLoading(btn, true);
@@ -1501,14 +2102,14 @@ async function runConfig() {
if (r.success) { if (r.success) {
setStatus('configStatus', r.message, 'success'); setStatus('configStatus', r.message, 'success');
log(r.message); log(r.message);
setTimeout(function() { goStep(5); }, 500); setTimeout(function() { goStep(6); }, 500);
} else { } else {
setStatus('configStatus', r.message, 'error'); setStatus('configStatus', r.message, 'error');
log('FAILED: ' + r.message); log('FAILED: ' + r.message);
} }
} }
// Step 5 // Step 6
async function loadAdmins() { async function loadAdmins() {
const sel = document.getElementById('adminSelect'); const sel = document.getElementById('adminSelect');
while (sel.firstChild) sel.removeChild(sel.firstChild); while (sel.firstChild) sel.removeChild(sel.firstChild);
@@ -1553,20 +2154,65 @@ async function runResetAdmin() {
if (r.success) { if (r.success) {
setStatus('adminStatus', r.message, 'success'); setStatus('adminStatus', r.message, 'success');
log(r.message); log(r.message);
setTimeout(function() { goStep(6); }, 500); setTimeout(function() { goStep(7); }, 500);
} else { } else {
setStatus('adminStatus', r.message, 'error'); setStatus('adminStatus', r.message, 'error');
log('FAILED: ' + r.message); 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() { async function runProvision() {
const btn = document.getElementById('btnProvision'); const btn = document.getElementById('btnProvision');
const tasks = []; const tasks = [];
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); }); 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); setBtnLoading(btn, true);
log('Running ' + tasks.length + ' provisioning tasks...'); log('Running ' + tasks.length + ' provisioning tasks...');
@@ -1579,14 +2225,14 @@ async function runProvision() {
if (r.success) { if (r.success) {
setStatus('provisionStatus', r.message, 'success'); setStatus('provisionStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); }); r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(7); }, 500); setTimeout(function() { goStep(9); }, 500);
} else { } else {
setStatus('provisionStatus', r.message, 'error'); setStatus('provisionStatus', r.message, 'error');
log('FAILED: ' + r.message); log('FAILED: ' + r.message);
} }
} }
// Step 7 // Step 9
async function runCleanup() { async function runCleanup() {
log('Cleaning up restore files...'); log('Cleaning up restore files...');
const r = await post('cleanup'); const r = await post('cleanup');
@@ -7,7 +7,7 @@
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE * @license GNU General Public License version 3 or later; see LICENSE
* *
* Resolves placeholders like [host], [date], [profile_name] in backup * Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
* directory paths and archive filename formats. * directory paths and archive filename formats.
*/ */
@@ -24,21 +24,21 @@ class PlaceholderResolver
* Supported placeholders and their descriptions (for documentation). * Supported placeholders and their descriptions (for documentation).
*/ */
public const PLACEHOLDERS = [ public const PLACEHOLDERS = [
'[host]' => 'Server hostname', '[HOST]' => 'Server hostname',
'[date]' => 'Date as Ymd (e.g. 20260604)', '[DATE]' => 'Date as Ymd (e.g. 20260604)',
'[time]' => 'Time as His (e.g. 143025)', '[TIME]' => 'Time as His (e.g. 143025)',
'[datetime]' => 'Date and time as Ymd_His', '[DATETIME]' => 'Date and time as Ymd_His',
'[year]' => 'Four-digit year', '[YEAR]' => 'Four-digit year',
'[month]' => 'Two-digit month', '[MONTH]' => 'Two-digit month',
'[day]' => 'Two-digit day', '[DAY]' => 'Two-digit day',
'[hour]' => 'Two-digit hour (24h)', '[HOUR]' => 'Two-digit hour (24h)',
'[minute]' => 'Two-digit minute', '[MINUTE]' => 'Two-digit minute',
'[second]' => 'Two-digit second', '[SECOND]' => 'Two-digit second',
'[profile_id]' => 'Backup profile ID', '[PROFILE_ID]' => 'Backup profile ID',
'[profile_name]' => 'Profile title (sanitized)', '[PROFILE_NAME]' => 'Profile title (sanitized)',
'[site_name]' => 'Joomla site name (sanitized)', '[SITE_NAME]' => 'Joomla site name (sanitized)',
'[type]' => 'Backup type (full, database, files, differential)', '[TYPE]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string', '[RANDOM]' => 'Random 6-character hex string',
'[DEFAULT_DIR]' => 'Default backup directory', '[DEFAULT_DIR]' => 'Default backup directory',
'[HOME]' => 'Home directory of the PHP process owner', '[HOME]' => 'Home directory of the PHP process owner',
]; ];
@@ -51,7 +51,32 @@ class PlaceholderResolver
public function __construct(object $profile) public function __construct(object $profile)
{ {
$now = new \DateTimeImmutable('now'); $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 = ''; $siteName = '';
@@ -62,21 +87,21 @@ class PlaceholderResolver
} }
$this->replacements = [ $this->replacements = [
'[host]' => $hostname, '[HOST]' => $hostname,
'[date]' => $now->format('Ymd'), '[DATE]' => $now->format('Ymd'),
'[time]' => $now->format('His'), '[TIME]' => $now->format('His'),
'[datetime]' => $now->format('Ymd_His'), '[DATETIME]' => $now->format('Ymd_His'),
'[year]' => $now->format('Y'), '[YEAR]' => $now->format('Y'),
'[month]' => $now->format('m'), '[MONTH]' => $now->format('m'),
'[day]' => $now->format('d'), '[DAY]' => $now->format('d'),
'[hour]' => $now->format('H'), '[HOUR]' => $now->format('H'),
'[minute]' => $now->format('i'), '[MINUTE]' => $now->format('i'),
'[second]' => $now->format('s'), '[SECOND]' => $now->format('s'),
'[profile_id]' => (string) ($profile->id ?? '0'), '[PROFILE_ID]' => (string) ($profile->id ?? '0'),
'[profile_name]' => $this->sanitize($profile->title ?? 'default'), '[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
'[site_name]' => $this->sanitize($siteName ?: 'joomla'), '[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
'[type]' => $profile->backup_type ?? 'full', '[TYPE]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)), '[RANDOM]' => bin2hex(random_bytes(3)),
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(), '[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(), '[HOME]' => BackupDirectory::getHomeDirectory(),
]; ];
@@ -103,7 +128,7 @@ class PlaceholderResolver
*/ */
public function getHostname(): string public function getHostname(): string
{ {
return $this->replacements['[host]']; return $this->replacements['[HOST]'];
} }
/** /**
@@ -111,7 +136,7 @@ class PlaceholderResolver
*/ */
public function getTag(): string public function getTag(): string
{ {
return $this->replacements['[datetime]']; return $this->replacements['[DATETIME]'];
} }
/** /**
@@ -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->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); $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 // Resolve placeholders in directory and filename
$resolver = new PlaceholderResolver($profile); $resolver = new PlaceholderResolver($profile);
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir)); $backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
@@ -81,9 +85,21 @@ class SteppedBackupEngine
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir]; return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
} }
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$tag = $resolver->getTag(); $tag = $resolver->getTag();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; $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'; $archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName; $session->archivePath = $backupDir . '/' . $archiveName;
@@ -135,13 +151,22 @@ class SteppedBackupEngine
} }
$totalSteps += 1; // finalize step $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->totalSteps = $totalSteps;
$session->currentStep = 1; $session->currentStep = 1;
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description); $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 // Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) { foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning); $session->log('PREFLIGHT WARNING: ' . $warning);
@@ -379,7 +404,17 @@ class SteppedBackupEngine
$db->updateObject('#__mokosuitebackup_records', $update, 'id'); $db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->currentStep++; $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->statusMessage = 'Archive finalized: ' . $sizeHuman;
$session->log('Archive finalized: ' . $sizeHuman); $session->log('Archive finalized: ' . $sizeHuman);
@@ -390,6 +425,10 @@ class SteppedBackupEngine
/** /**
* Upload phase: send archive to remote storage. * 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 private function stepUpload(SteppedSession $session): void
{ {
@@ -397,62 +436,126 @@ class SteppedBackupEngine
$remoteFilename = ''; $remoteFilename = '';
$uploadFailed = false; $uploadFailed = false;
// Wrapped in its own try-catch so a remote failure does not mark if (!empty($session->remoteDestinations)) {
// the entire backup as failed — the local archive is preserved. // ── Multi-remote path ──────────────────────────────────
try { $index = $session->remoteIndex;
// 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) { if ($index >= count($session->remoteDestinations)) {
'ftp' => new FtpUploader($profile), // All remotes processed — move to complete
'sftp' => new SftpUploader($profile), $session->phase = 'complete';
'google_drive' => new GoogleDriveUploader($profile), $session->statusMessage = 'All remote uploads finished';
's3' => new S3Uploader($profile), $this->completeRecord($session);
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...'); return;
$result = $uploader->upload($session->archivePath, $session->archiveName); }
if ($result['success']) { $remote = (object) $session->remoteDestinations[$index];
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
if (!$session->remoteKeepLocal && is_file($session->archivePath)) { try {
@unlink($session->archivePath); $title = $remote->title ?? ('Remote #' . ($index + 1));
$session->log('Local copy removed'); $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; $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.'); $session->log('Local backup is preserved.');
} }
} catch (\Throwable $e) {
$uploadFailed = true; // Update record with remote filename
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage()); $update = (object) [
$session->log('Local backup is preserved.'); '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; 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 bool $remoteKeepLocal = true;
public string $encryptionPassword = ''; public string $encryptionPassword = '';
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
public array $remoteDestinations = [];
public int $remoteIndex = 0;
// Progress // Progress
public int $totalSteps = 0; public int $totalSteps = 0;
public int $currentStep = 0; public int $currentStep = 0;
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
} }
// Build placeholder map for JS resolution // 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 = ''; $siteName = '';
try { try {
@@ -52,15 +75,15 @@ class FolderPickerField extends FormField
$placeholders = [ $placeholders = [
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(), '[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(), '[HOME]' => BackupDirectory::getHomeDirectory(),
'[host]' => $hostname, '[HOST]' => $hostname,
'[site_name]' => $sanitizedSiteName ?: 'joomla', '[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
'[profile_id]' => '1', '[PROFILE_ID]' => '1',
'[profile_name]' => 'default', '[PROFILE_NAME]' => 'default',
'[type]' => 'full', '[TYPE]' => 'full',
'[year]' => date('Y'), '[YEAR]' => date('Y'),
'[month]' => date('m'), '[MONTH]' => date('m'),
'[day]' => date('d'), '[DAY]' => date('d'),
'[date]' => date('Ymd'), '[DATE]' => date('Ymd'),
]; ];
$placeholdersJson = json_encode($placeholders); $placeholdersJson = json_encode($placeholders);
@@ -96,7 +119,7 @@ class FolderPickerField extends FormField
<span class="icon-folder-open" aria-hidden="true"></span> <span class="icon-folder-open" aria-hidden="true"></span>
Browse Browse
</button> </button>
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders"> <button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
<span class="icon-question-circle" aria-hidden="true"></span> <span class="icon-question-circle" aria-hidden="true"></span>
</button> </button>
</div> </div>
@@ -104,12 +127,12 @@ class FolderPickerField extends FormField
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span> <span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[host]" title="Server hostname">[host]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[site_name]" title="Joomla site name">[site_name]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[date]" title="Date (Ymd)">[date]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_id]" title="Profile ID">[profile_id]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_name]" title="Profile name">[profile_name]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[type]" title="Backup type">[type]</button> <button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
</div> </div>
<div class="mt-1" id="{$id}_status"> <div class="mt-1" id="{$id}_status">
<small class="{$statusClass}"> <small class="{$statusClass}">
@@ -117,41 +140,119 @@ class FolderPickerField extends FormField
{$statusDetail} {$statusDetail}
</small> </small>
</div> </div>
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
</div>
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;"> <div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
<span class="icon-warning-circle" aria-hidden="true"></span> <span class="icon-warning-circle" aria-hidden="true"></span>
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root. The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
</div> </div>
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true"> <div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5> <h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
<h6 class="text-primary">How Path Resolution Works</h6>
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
<div class="card mb-3">
<div class="card-header fw-bold">Absolute Paths</div>
<div class="card-body py-2">
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
<ul class="mb-0">
<li><code>/home/user/backups</code> — Fixed path on the server</li>
<li><code>/var/backups/joomla</code> — System backup directory</li>
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Relative Paths</div>
<div class="card-body py-2">
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
<table class="table table-sm mb-2">
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
<tbody>
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
</tbody>
</table>
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
<div class="card-body py-2">
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
<ul class="mb-0">
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
</ul>
</div>
</div>
<h6 class="text-primary mt-3">Available Placeholders</h6>
<table class="table table-sm table-striped"> <table class="table table-sm table-striped">
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead> <thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
<tbody> <tbody>
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr> <tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr> <tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr> <tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr> <tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr> <tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr> <tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr> <tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr> <tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr> <tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr> <tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr> <tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
</tbody> </tbody>
</table> </table>
<h6>Recommended Paths</h6>
<ul class="list-unstyled"> <h6 class="text-primary mt-3">Recommended Configurations</h6>
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li> <table class="table table-sm">
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li> <thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li> <tbody>
</ul> <tr>
<td><strong>Single site, secure</strong></td>
<td><code>[HOME]/backups</code></td>
<td>Outside web root. Best for most sites.</td>
</tr>
<tr>
<td><strong>Multiple sites on one server</strong></td>
<td><code>[HOME]/backups/[HOST]</code></td>
<td>Each site gets its own subdirectory.</td>
</tr>
<tr>
<td><strong>Date-organized</strong></td>
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
<td>Backups sorted by year and month.</td>
</tr>
<tr>
<td><strong>Per-profile</strong></td>
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
<td>Separate directory for each backup profile.</td>
</tr>
<tr>
<td><strong>Shared hosting (default)</strong></td>
<td><code>[DEFAULT_DIR]</code></td>
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
</tr>
</tbody>
</table>
<div class="alert alert-info py-2 mt-3 mb-0">
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -186,6 +287,36 @@ class FolderPickerField extends FormField
}); });
}); });
/* Help button — open modal with Bootstrap 5 or fallback */
var helpBtn = document.getElementById('{$id}_helpBtn');
var helpModal = document.getElementById('{$id}_helpModal');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', function(e) {
e.preventDefault();
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
modal.show();
} else {
helpModal.classList.add('show');
helpModal.style.display = 'block';
helpModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = '{$id}_backdrop';
document.body.appendChild(backdrop);
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
helpModal.classList.remove('show');
helpModal.style.display = 'none';
helpModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
var bd = document.getElementById('{$id}_backdrop');
if (bd) bd.remove();
});
}
});
}
var fieldId = '{$id}'; var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn'); var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser'); var browser = document.getElementById(fieldId + '_browser');
@@ -193,7 +324,7 @@ class FolderPickerField extends FormField
var input = document.getElementById(fieldId); var input = document.getElementById(fieldId);
var placeholders = {$placeholdersJson}; var placeholders = {$placeholdersJson};
// Resolve placeholders in a path (forward: [site_name] -> actual value) // Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
function resolve(path) { function resolve(path) {
for (var key in placeholders) { for (var key in placeholders) {
path = path.split(key).join(placeholders[key]); path = path.split(key).join(placeholders[key]);
@@ -284,8 +415,54 @@ class FolderPickerField extends FormField
}); });
} }
/* Show which placeholders are in use and their resolved values */
var resolvedDiv = document.getElementById(fieldId + '_resolved');
function updateResolvedDisplay() {
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
var val = input.value || '';
var found = false;
for (var key in placeholders) {
if (val.indexOf(key) !== -1 && placeholders[key]) {
found = true;
var badge = document.createElement('span');
badge.className = 'badge bg-light text-dark border me-1 mb-1';
badge.style.fontSize = '0.75rem';
badge.style.fontFamily = 'monospace';
var keySpan = document.createElement('strong');
keySpan.textContent = key;
badge.appendChild(keySpan);
badge.appendChild(document.createTextNode(' = '));
var valSpan = document.createElement('span');
valSpan.className = 'text-primary';
valSpan.textContent = placeholders[key];
badge.appendChild(valSpan);
resolvedDiv.appendChild(badge);
}
}
if (found) {
var fullResolved = document.createElement('div');
fullResolved.className = 'mt-1';
var arrow = document.createElement('span');
arrow.className = 'text-muted';
arrow.textContent = 'EXAMPLE: ';
fullResolved.appendChild(arrow);
var code = document.createElement('code');
code.textContent = resolve(val);
fullResolved.appendChild(code);
resolvedDiv.appendChild(fullResolved);
}
}
input.addEventListener('input', function() { input.addEventListener('input', function() {
clearTimeout(checkTimer); clearTimeout(checkTimer);
updateResolvedDisplay();
checkTimer = setTimeout(checkDirPermissions, 400); checkTimer = setTimeout(checkDirPermissions, 400);
}); });
@@ -399,6 +576,7 @@ class FolderPickerField extends FormField
// Run initial check on page load // Run initial check on page load
setDefaultDirWarning(); setDefaultDirWarning();
updateResolvedDisplay();
checkDirPermissions(); checkDirPermissions();
})(); })();
</script> </script>
@@ -33,8 +33,8 @@ class PlaceholderTextField extends FormField
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr))); $placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
if (empty($placeholders)) { if (empty($placeholders)) {
$placeholders = ['[host]', '[date]', '[datetime]', '[time]', '[year]', '[month]', '[day]', $placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
'[hour]', '[minute]', '[second]', '[profile_id]', '[profile_name]', '[site_name]', '[type]', '[random]']; '[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
} }
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"' $html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
@@ -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 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.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
} }
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
if ($user->authorise('core.manage', 'com_mokosuitebackup')) { 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); 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'); 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')) { if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
ToolbarHelper::preferences('com_mokosuitebackup'); ToolbarHelper::preferences('com_mokosuitebackup');
} }
@@ -65,7 +65,7 @@ class HtmlView extends BaseHtmlView
} }
// "View Backups" link button // "View Backups" link button
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $profileId); $backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS') $toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
->url($backupsUrl) ->url($backupsUrl)
->icon('icon-database') ->icon('icon-database')
@@ -695,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div> </div>
</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 --> <!-- 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 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;"> <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> </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\HTML\HTMLHelper;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route; use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.formvalidator'); HTMLHelper::_('behavior.formvalidator');
HTMLHelper::_('behavior.keepalive'); 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"> method="post" name="adminForm" id="adminForm" class="form-validate">
<div class="main-card"> <div class="main-card">
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?> <?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
<div class="row"> <div class="row">
<div class="col-lg-9"> <div class="col-lg-12">
<?php echo $this->form->renderFieldset('remote'); ?> <?php // ---- Remote Destinations (multi-remote) ---- ?>
<?php echo $this->form->renderFieldset('ftp'); ?> <?php if ($profileId): ?>
<?php echo $this->form->renderFieldset('google_drive'); ?> <div id="mokoRemoteDestinations" class="mb-4">
<?php echo $this->form->renderFieldset('s3'); ?> <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>
</div> </div>
<?php echo HTMLHelper::_('uitab.endTab'); ?> <?php echo HTMLHelper::_('uitab.endTab'); ?>
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
<input type="hidden" name="task" value=""> <input type="hidden" name="task" value="">
<?php echo HTMLHelper::_('form.token'); ?> <?php echo HTMLHelper::_('form.token'); ?>
</form> </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; ?>
@@ -78,7 +78,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<?php echo $this->escape($item->backup_type); ?> <?php echo $this->escape($item->backup_type); ?>
</td> </td>
<td class="text-center"> <td class="text-center">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $item->id); ?>"> <a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>"> <span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
<?php echo (int) $item->backup_count; ?> <?php echo (int) $item->backup_count; ?>
</span> </span>
@@ -403,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var label = document.createElement('label'); var label = document.createElement('label');
label.className = 'form-check-label'; label.className = 'form-check-label';
label.setAttribute('for', 'mb-rtype-' + type); label.setAttribute('for', 'mb-rtype-' + type);
label.textContent = typeLabels[type] || type; label.textContent = typeLabels[TYPE] || type;
div.appendChild(input); div.appendChild(input);
div.appendChild(label); div.appendChild(label);
@@ -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"> <extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name> <name>Action Log - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="console" method="upgrade"> <extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name> <name>Console - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="content" method="upgrade"> <extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name> <name>Content - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-04</creationDate> <creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade"> <extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name> <name>Quick Icon - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name> <name>System - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name> <name>Task - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
--> -->
<extension type="plugin" group="webservices" method="upgrade"> <extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name> <name>Web Services - MokoSuiteBackup</name>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
+2 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name> <name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename> <packagename>mokosuitebackup</packagename>
<version>01.37.00</version> <version>01.41.03</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -28,6 +28,7 @@
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file> <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="content">plg_content_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_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> </files>
<languages> <languages>