Compare commits

..

91 Commits

Author SHA1 Message Date
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
gitea-actions[bot] 70d7da34b3 chore: promote changelog [Unreleased] → [01.37.00] 2026-06-23 16:04:44 +00:00
gitea-actions[bot] 13c251196b chore(release): build 01.37.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-23 16:04:41 +00:00
jmiller 4841f24eab Merge pull request 'feat: Profiles UI, snapshot detail, progress warning, action logs' (#120) from feat/batch-ui-fixes into main 2026-06-23 16:03:42 +00:00
Jonathan Miller 64ffbb9d61 feat: profiles UI, snapshot detail, progress warning, action logs (#100, #104, #108, #110)
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 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 10s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 18s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4m10s
#100: Run Backup button on profiles list (per-row) and edit toolbar,
backup count badge linking to filtered backups view, View Backups
toolbar button on profile edit.

#101: Profile → filtered backup list link (included in #100).

#104: Snapshot browse modal now shows tabbed view (Articles,
Categories, Modules) with item counts. AjaxController returns
all content types. Categories show indented hierarchy.

#108: "Do not navigate away or close this window" warning banner
added to both backup and restore progress modals.

#110: Joomla Action Logs integration — RestoreEngine, SnapshotEngine,
and SnapshotRestoreEngine now dispatch events that the actionlog
plugin logs to #__action_logs.

Closes #100, closes #101, closes #104, closes #108, closes #110
2026-06-23 11:03:13 -05:00
jmiller 83e91c6fa6 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 15:50:28 +00:00
jmiller b1833825e7 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 15:50:27 +00:00
jmiller bde20e82ad chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 15:50:26 +00:00
jmiller 8348d23fe4 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 15:50:25 +00:00
gitea-actions[bot] d9557489d5 chore: promote changelog [Unreleased] → [01.36.00] 2026-06-23 15:48:58 +00:00
gitea-actions[bot] 089ec69595 chore(release): build 01.36.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 15:48:50 +00:00
jmiller 7427cbb043 Merge pull request 'feat: Clickable placeholder pills for backup dir and archive name' (#102) from feat/clickable-placeholders into main 2026-06-23 15:48:34 +00:00
Jonathan Miller 456e744d81 feat: clickable placeholder pills for backup dir and archive name fields
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 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 2s
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 10s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 26s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 9m59s
Both Backup Directory and Archive Name Format fields now show clickable
placeholder tags below the input. Clicking a tag inserts the placeholder
at the current cursor position using selectionStart/End.

- FolderPickerField: pills for [HOME], [DEFAULT_DIR], [host], [site_name],
  [date], [profile_id], [profile_name], [type]
- PlaceholderTextField: new custom field type used by archive_name_format,
  configurable placeholders via XML attribute
- Cursor position preserved after insert, input event dispatched for
  live status updates
2026-06-23 10:47:45 -05:00
gitea-actions[bot] 6d5ef50727 chore: promote changelog [Unreleased] → [01.35.04] 2026-06-23 15:41:32 +00:00
gitea-actions[bot] 00e7963988 chore(release): build 01.35.04 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-23 15:41:29 +00:00
jmiller bc06657317 Merge pull request 'fix: SFTP fields not showing when SFTP selected' (#99) from fix/sftp-key-upload into main 2026-06-23 15:40:43 +00:00
Jonathan Miller bda4b0a23d Merge branch 'fix/sftp-key-upload' of https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup into fix/sftp-key-upload
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
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 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
RC Revert / Rename rc/ back to dev/ (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
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 38s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 44s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 21s
# Conflicts:
#	.mokogitea/workflows/issue-branch.yml
#	README.md
#	source/packages/com_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml
#	source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml
#	source/pkg_mokosuitebackup.xml
2026-06-23 10:40:27 -05:00
Jonathan Miller e327f9cf5c chore: normalize workflows 2026-06-23 10:40:16 -05:00
Jonathan Miller 5b9351e5f0 Merge remote-tracking branch 'origin/main' into fix/sftp-key-upload 2026-06-23 10:38:26 -05:00
gitea-actions[bot] 5785e9fd1e chore(version): pre-release bump to 01.35.04-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 32s
2026-06-23 15:37:59 +00:00
Jonathan Miller 1e9c8d54f4 fix: remove required attr from SFTP showon fields — blocks save when not SFTP
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 22s
Joomla validates required fields server-side regardless of showon
visibility. SFTP fields with required="true" block saving when
remote_storage is set to None or S3/GDrive because the hidden
fields submit empty values. Validation should be done in
ProfileTable::check() conditionally instead.
2026-06-23 10:37:38 -05:00
jmiller 7515274712 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 13:54:34 +00:00
jmiller 0be459fe34 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 13:54:33 +00:00
jmiller 11ccdbfde4 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 13:54:32 +00:00
jmiller fd517c16f3 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 13:54:31 +00:00
57 changed files with 4809 additions and 1726 deletions
+66 -66
View File
@@ -1,66 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
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
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.02.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup mokocli tools
run: |
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
fi
if [ -d "/opt/mokocli/cli" ]; then
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
/tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/mokocli/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--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
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.35.03
# VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened
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
+24 -12
View File
@@ -1,18 +1,30 @@
# Changelog
## [Unreleased]
## [01.35.03] --- 2026-06-23
## [01.39.01] --- 2026-06-23
## [01.35.03] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.35.01] --- 2026-06-23
## [01.35.00] --- 2026-06-23
## [01.35.00] --- 2026-06-23
## [01.39.01] --- 2026-06-23
### Added
- SFTP remote storage with SSH key file authentication — key stored securely in database
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
- MokoRestore: post-restore reset optionspasswords, hits, versions, sessions, cache (#131)
- MokoRestore: per-table conflict resolution — replace, skip, merge, data-only per table (#132)
- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users"
- MokoRestore: auto-detect sanitized passwords and prompt for reset
- Data sanitization: passwords, emails, sessions in backup profile settings (#129)
- Manual purge: delete all backups older than a selected date with count preview (#119)
- CPanel admin dashboard module with backup status, quick actions, and profile buttons (#105)
- 7z archive format via system 7za/7z binary with optional password encryption (#122)
- SFTP remote file browser: browse remote server directories to select backup path (#98)
### Fixed
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
- MokoRestore: temporary password is now randomly generated (not hardcoded "changeme")
## [01.38.05] --- 2026-06-23
## [01.38.05] --- 2026-06-23
## [01.38.04] --- 2026-06-23
## [01.38.04] --- 2026-06-23
+64 -34
View File
@@ -1,50 +1,80 @@
# MokoSuiteBackup
<!-- VERSION: 01.35.03 -->
Full-site backup and restore for Joomla — database, files, and configuration.
## Overview
MokoSuiteBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management.
| Field | Value |
|---|---|
| **Package** | `pkg_mokosuitebackup` |
| **Type** | Joomla Package (8 sub-extensions) |
| **Joomla** | 6.x+ |
| **PHP** | 8.1+ |
| **License** | GPL-3.0-or-later |
## Features
- Full site backup (database + files + configuration)
- Database-only backup mode
- Files-only backup mode
- Multiple backup profiles with independent configurations
- File and directory exclusion filters
- Table exclusion filters for database backups
- Step-based backup engine (avoids PHP timeout on large sites)
- CLI script for cron/scheduled backups
- REST API (Joomla Web Services) for remote management
- Backup record management (list, download, delete)
- Automatic old backup cleanup (configurable retention)
- Admin dashboard with backup history and storage usage
### Backup
- Full site, database-only, files-only, and differential backup modes
- Pre-flight validation — checks directory, disk space, extensions, credentials before starting
- Auto-verify archive integrity after creation
- Stepped AJAX engine prevents timeout on shared hosting
- AES-256 ZIP encryption with configurable password
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
### Content Snapshots
- Lightweight JSON snapshots of articles, categories, and modules
- Includes tags, custom fields, workflow associations
- Restore modes: Replace (clean slate) or Merge (upsert)
- Selective article restore — browse and pick individual items
- Automatic retention (max count + max age)
- Scheduled snapshot task via com_scheduler
### Remote Storage
- SFTP with SSH key file authentication (key stored base64-encoded in database)
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
- Google Drive with OAuth2 and resumable uploads
- Graceful degradation — local backup preserved if upload fails
### MokoRestore Standalone Wizard
- 9-step restore wizard that works without Joomla installed
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
- Post-restore actions: reset passwords, hits, versions, sessions, cache
- Auto-detect sanitized passwords and prompt for reset
- Standalone mode: restore.php scans directory for ZIP files
- Wrapped mode: restore.php bundled inside backup ZIP
- Security gate with filesystem verification
### Notifications
- Email on success/failure per profile
- ntfy push notifications
- Notifications for restore and snapshot operations
### Admin Dashboard
- Last backup status, next scheduled, total count, storage used
- Snapshot widget with latest info and type badges
- 30-day backup trend chart
- Per-profile storage breakdown
- System health checks
### CLI
- `mokosuitebackup:run --profile=1` — run backup
- `mokosuitebackup:restore 1 --files-only --db-only --password=xxx`
- `mokosuitebackup:snapshot create|restore|list|delete`
### REST API
- Backup: start, list, download, delete, profiles
- Snapshots: create, list, restore, delete, download
- Profile credentials masked in API responses
## Installation
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
2. Joomla Administrator > Extensions > Install
3. System plugin enabled automatically on install
3. Components > MokoSuiteBackup > Dashboard
## Configuration
## Documentation
- **Component**: Administrator > Components > MokoSuiteBackup
- **Profiles**: Create backup profiles with different file/database filters
- **System Plugin**: Configure scheduled backup triggers and notifications
- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups
## REST API
The webservices plugin exposes endpoints compatible with the MokoBackup MCP server:
- `POST /api/index.php/v1/mokobackup/backup` — Start a backup
- `GET /api/index.php/v1/mokobackup/backups` — List backup records
- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive
- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record
- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles
See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
## License
-1
View File
@@ -1 +0,0 @@
<!DOCTYPE html><title></title>
@@ -40,6 +40,7 @@
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
</field>
<field
name="compression_level"
@@ -72,23 +73,25 @@
/>
<field
name="archive_name_format"
type="text"
type="PlaceholderText"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
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]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="include_mokorestore"
type="radio"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
<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="encryption_password"
@@ -99,6 +102,54 @@
/>
</fieldset>
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
<field
name="sanitize_passwords"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="preserve_super_admin"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
default="1"
class="btn-group"
showon="sanitize_passwords:1"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_emails"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_sessions"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
<field
name="id"
@@ -182,7 +233,6 @@
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
maxlength="255"
required="true"
showon="remote_storage:sftp"
/>
<field
@@ -201,7 +251,6 @@
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
maxlength="255"
required="true"
showon="remote_storage:sftp"
/>
<field
@@ -222,7 +271,6 @@
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
maxlength="255"
required="true"
showon="remote_storage:sftp[AND]sftp_auth_type:password"
/>
<field
@@ -240,18 +288,17 @@
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
maxlength="255"
required="true"
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
/>
<field
name="sftp_path"
type="text"
type="SftpPath"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
default="/backups"
maxlength="512"
required="true"
showon="remote_storage:sftp"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
@@ -78,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
; Profile actions
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
; Table headings
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
@@ -113,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
; Archive settings
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
@@ -124,11 +131,25 @@ COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="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_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_INCLUDE_MOKORESTORE="Include Restore 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_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_INCLUDE_MOKORESTORE="MokoRestore Script"
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
COM_MOKOJOOMBACKUP_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 all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
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 all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
; Exclusion filter fields
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
@@ -415,6 +436,33 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
; Snapshot browse / detail view
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
COM_MOKOJOOMBACKUP_LOADING="Loading..."
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
; Purge
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
; Errors
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -99,3 +103,16 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
; Purge
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<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',
`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]',
`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_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns 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',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_mokorestore` 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_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
@@ -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`;
-- 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`;
@@ -15,8 +15,10 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
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\SteppedRestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
@@ -283,7 +285,32 @@ class AjaxController extends BaseController
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)) {
$this->sendJson([
@@ -622,28 +649,118 @@ class AjaxController extends BaseController
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
return;
}
$tables = $data['tables'] ?? [];
// Articles
$articles = [];
foreach ($data['tables']['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
if (!empty($tables['#__content'])) {
foreach ($tables['#__content'] as $row) {
$articles[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'catid' => (int) ($row['catid'] ?? 0),
'state' => (int) ($row['state'] ?? 0),
'created' => $row['created'] ?? '',
];
}
}
// Categories
$categories = [];
if (!empty($tables['#__categories'])) {
foreach ($tables['#__categories'] as $row) {
$categories[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'extension' => $row['extension'] ?? '',
'published' => (int) ($row['published'] ?? 0),
'level' => (int) ($row['level'] ?? 0),
];
}
}
// Modules
$modules = [];
if (!empty($tables['#__modules'])) {
foreach ($tables['#__modules'] as $row) {
$modules[] = [
'id' => (int) ($row['id'] ?? 0),
'title' => $row['title'] ?? '',
'module' => $row['module'] ?? '',
'position' => $row['position'] ?? '',
'published' => (int) ($row['published'] ?? 0),
];
}
}
$this->sendJson([
'error' => false,
'articles' => $articles,
'total' => count($articles),
'error' => false,
'articles' => $articles,
'categories' => $categories,
'modules' => $modules,
'total_articles' => \count($articles),
'total_categories' => \count($categories),
'total_modules' => \count($modules),
]);
}
/**
* 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('core.delete', '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,
]);
}
@@ -762,6 +879,184 @@ class AjaxController extends BaseController
]);
}
/**
* 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.
*/
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
*/
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')) {
$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));
}
/**
* 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('core.delete', '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.
*/
@@ -87,8 +87,14 @@ class BackupEngine
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
// Pass encryption password to 7z archiver (handles it natively via -p flag)
if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
$archiver->setEncryptionPassword($profile->encryption_password);
}
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) {
@@ -137,7 +143,19 @@ class BackupEngine
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
if ($sanitizePasswords) {
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
}
if ($sanitizeEmails) {
$this->log('User emails will be sanitized');
}
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount();
@@ -216,12 +234,14 @@ class BackupEngine
$encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) {
if ($archiveFormat !== 'zip') {
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
} else {
if ($archiveFormat === 'zip') {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
} elseif ($archiveFormat === '7z') {
$this->log('Archive encrypted with AES-256 (7z native encryption)');
} else {
$this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
}
}
@@ -237,26 +257,32 @@ class BackupEngine
$this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified');
// Step 2.5: Wrap with MokoRestore script (if enabled)
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
// Step 2.5: MokoRestore script (if enabled)
$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...');
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
MokoRestore::wrap($archivePath, $mokoRestorePath);
// Replace the original archive with the wrapped one
if (is_file($archivePath) && !unlink($archivePath)) {
$this->log('WARNING: Could not remove pre-wrap archive');
}
rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
// Recompute checksum for the final wrapped archive
$checksum = hash_file('sha256', $archivePath);
$this->log('MokoRestore archive created: ' . $sizeHuman);
$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 = '';
@@ -277,6 +303,18 @@ class BackupEngine
$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);
@@ -296,7 +334,7 @@ class BackupEngine
// Write log file alongside the archive
$logContent = implode("\n", $this->log);
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
$logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
@@ -442,6 +480,7 @@ class BackupEngine
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
'7z' => new SevenZipArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
};
}
@@ -547,6 +586,13 @@ class BackupEngine
return;
}
// 7z verification via CLI
if ($extension === '7z') {
$this->verify7zArchive($archivePath);
return;
}
// ZIP verification
$zip = new \ZipArchive();
@@ -608,6 +654,64 @@ class BackupEngine
}
}
/**
* Verify a 7z archive using the CLI binary.
*
* @param string $archivePath Absolute path to the .7z file
*
* @throws \RuntimeException If the archive fails verification
*/
private function verify7zArchive(string $archivePath): void
{
// Test the archive with 7z t (test integrity)
$candidates = PHP_OS_FAMILY === 'Windows'
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
$binary = null;
foreach ($candidates as $candidate) {
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
if (is_file($candidate) && is_executable($candidate)) {
$binary = $candidate;
break;
}
continue;
}
$whichCmd = PHP_OS_FAMILY === 'Windows'
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
$result = trim((string) shell_exec($whichCmd));
if ($result !== '' && is_executable($result)) {
$binary = $result;
break;
}
}
if ($binary === null) {
// Cannot verify without the binary — log warning but don't fail
$this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
return;
}
$cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
throw new \RuntimeException(
'Archive integrity check failed: 7z test exited with code ' . $exitCode
. ': ' . implode("\n", array_slice($output, -5))
);
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -27,12 +27,35 @@ class DatabaseDumper
private int $tablesCount = 0;
/** @var bool Whether to sanitize user passwords */
private bool $sanitizePasswords = false;
/** @var bool Whether to preserve super admin password when sanitizing */
private bool $preserveSuperAdmin = false;
/** @var bool Whether to sanitize user emails */
private bool $sanitizeEmails = false;
/** @var bool Whether to clear session data */
private bool $sanitizeSessions = false;
/** Known invalid bcrypt hash used for sanitized passwords */
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
/**
* @param array $excludeTables Table names to exclude (with #__ prefix).
* Supports suffixes: :data-only, :structure-only.
* No suffix = exclude both (backward compatible).
* @param array $excludeTables Table names to exclude (with #__ prefix).
* @param bool $sanitizePasswords Replace user password hashes with invalid value
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
* @param bool $sanitizeSessions Skip session table data entirely
*/
public function __construct(array $excludeTables = [])
public function __construct(
array $excludeTables = [],
bool $sanitizePasswords = false,
bool $preserveSuperAdmin = false,
bool $sanitizeEmails = false,
bool $sanitizeSessions = false
)
{
foreach ($excludeTables as $entry) {
if (str_ends_with($entry, ':data-only')) {
@@ -43,6 +66,16 @@ class DatabaseDumper
$this->excludeBoth[] = $entry;
}
}
$this->sanitizePasswords = $sanitizePasswords;
$this->preserveSuperAdmin = $preserveSuperAdmin;
$this->sanitizeEmails = $sanitizeEmails;
$this->sanitizeSessions = $sanitizeSessions;
/* If session sanitization is on, auto-exclude session table data */
if ($sanitizeSessions) {
$this->excludeDataOnly[] = '#__session';
}
}
/**
@@ -154,6 +187,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -326,6 +360,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -351,6 +386,86 @@ class DatabaseDumper
return filesize($filePath) ?: 0;
}
/**
* Sanitize a row if it belongs to the users table and sanitization is enabled.
*
* Replaces the password column with an invalid hash so the backup
* cannot be used to extract user credentials.
*/
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
{
if ($abstractTable !== '#__users') {
return;
}
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
return;
}
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
$userId = (int) $row['id'];
/* Preserve super admin emails if preserving super admin */
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
$row['email'] = 'user' . $userId . '@sanitized.example.com';
}
}
if (!$this->sanitizePasswords || !isset($row['password'])) {
return;
}
if ($this->preserveSuperAdmin && isset($row['id'])) {
if ($this->isSuperAdmin((int) $row['id'], $db)) {
return;
}
}
$row['password'] = self::SANITIZED_HASH;
}
/**
* Check if a user ID belongs to the Super Users group (group_id = 8).
*/
private function isSuperAdmin(int $userId, object $db): bool
{
static $superAdminIds = null;
if ($superAdminIds === null) {
$prefix = $db->getPrefix();
try {
$db->setQuery(
$db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('user_id'))
->from($db->quoteName($prefix . 'user_usergroup_map'))
->where($db->quoteName('group_id') . ' = 8')
);
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
} catch (\Throwable $e) {
$superAdminIds = [];
}
}
return in_array($userId, $superAdminIds, true);
}
/**
* Check if passwords were sanitized (for use by callers to log the action).
*/
public function isPasswordSanitizationEnabled(): bool
{
return $this->sanitizePasswords;
}
/**
* Get the sentinel hash used for sanitized passwords.
*/
public static function getSanitizedHash(): string
{
return self::SANITIZED_HASH;
}
public function getTablesCount(): int
{
return $this->tablesCount;
@@ -54,6 +54,191 @@ class MokoRestore
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.
*
@@ -191,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
function handleAction(string $action, array $data): array
{
return match ($action) {
'preflight' => actionPreflight(),
'extract' => actionExtract($data),
'testdb' => actionTestDb($data),
'database' => actionDatabase($data),
'config' => actionConfig($data),
'listAdmins' => actionListAdmins($data),
'resetAdmin' => actionResetAdmin($data),
'provision' => actionProvision($data),
'cleanup' => actionCleanup(),
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
'preflight' => actionPreflight(),
'extract' => actionExtract($data),
'scanTables' => actionScanTables(),
'testdb' => actionTestDb($data),
'database' => actionDatabase($data),
'config' => actionConfig($data),
'listAdmins' => actionListAdmins($data),
'resetAdmin' => actionResetAdmin($data),
'postRestore' => actionPostRestore($data),
'detectSanitized' => detectSanitizedPasswords($data),
'provision' => actionProvision($data),
'cleanup' => actionCleanup(),
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
};
}
@@ -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
{
$host = $data['db_host'] ?? 'localhost';
@@ -423,10 +670,27 @@ function actionDatabase(array $data): array
// Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql);
// Decode per-table conflict resolution selections
// Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly
$tableResolutions = [];
if (!empty($data['table_resolutions'])) {
$decoded = json_decode($data['table_resolutions'], true);
if (is_array($decoded)) {
// Remap from abstract #__ names to the real prefix
foreach ($decoded as $abstractName => $mode) {
$realName = str_replace('#__', $prefix, $abstractName);
$tableResolutions[$realName] = $mode;
}
}
}
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
$errorList = [];
$skipped = 0;
foreach ($parts as $part) {
$part = trim($part);
@@ -435,6 +699,42 @@ function actionDatabase(array $data): array
continue;
}
// Determine which table this statement belongs to
$table = getStatementTable($part);
$mode = $tableResolutions[$table] ?? 'replace';
// Apply conflict resolution per table
if ($mode === 'skip') {
$skipped++;
continue;
}
$isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part);
$isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part);
$isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part);
if ($mode === 'merge') {
// Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part);
}
} elseif ($mode === 'dataonly') {
/* Skip DROP and CREATE; use REPLACE INTO for data (overwrites on duplicate key) */
if ($isDrop || $isCreate) {
$skipped++;
continue;
}
if ($isInsert) {
$part = preg_replace('/^INSERT\s+INTO/i', 'REPLACE INTO', $part);
}
}
// mode === 'replace' => execute everything as-is (default)
try {
$pdo->exec($part);
$statements++;
@@ -449,11 +749,22 @@ function actionDatabase(array $data): array
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
$msg = "Executed {$statements} statements";
if ($skipped > 0) {
$msg .= " ({$skipped} skipped)";
}
if ($errors > 0) {
$msg .= " ({$errors} warnings)";
}
return [
'success' => ($statements > 0 || $errors === 0),
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
'message' => $msg,
'statements' => $statements,
'errors' => $errors,
'skipped' => $skipped,
'errorList' => $errorList,
];
}
@@ -804,6 +1115,128 @@ function actionResetAdmin(array $data): array
return ['success' => true, 'message' => 'Admin password updated successfully'];
}
function actionPostRestore(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = [];
foreach ($tasks as $task) {
try {
switch ($task) {
case 'reset_passwords':
/* Set all user passwords to a random temporary hash, block non-admin users */
$tempPassword = bin2hex(random_bytes(8)); /* 16-char random hex */
// clear activation tokens, and force password reset on next login.
$tempHash = password_hash($tempPassword, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1"
);
$stmt->execute([$tempHash]);
$affected = $stmt->rowCount();
$results[] = "All {$affected} user password(s) reset to temporary password ({$tempPassword}) with forced reset";
break;
case 'reset_hits':
$pdo->exec("UPDATE {$prefix}content SET hits = 0");
$results[] = 'Content hits reset to 0';
break;
case 'clear_versions':
try {
$pdo->exec("TRUNCATE TABLE {$prefix}history");
$results[] = 'Content version history cleared';
} catch (PDOException $e) {
$results[] = 'Version history: table not found (skipped)';
}
break;
case 'clear_sessions':
$pdo->exec("TRUNCATE TABLE {$prefix}session");
$results[] = 'Sessions cleared';
break;
case 'clear_cache':
// Clear Joomla cache tables
foreach (['cache', 'cache_extension'] as $tbl) {
try {
$pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}");
} catch (PDOException $e) {
// Table may not exist
}
}
// Delete files in cache/ directory
$cacheDir = RESTORE_DIR . '/cache';
$cacheCount = 0;
if (is_dir($cacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
// Also clear administrator/cache/
$adminCacheDir = RESTORE_DIR . '/administrator/cache';
if (is_dir($adminCacheDir)) {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
if ($item->isFile()) {
@unlink($item->getPathname());
$cacheCount++;
} elseif ($item->isDir()) {
@rmdir($item->getPathname());
}
}
}
$results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed";
break;
default:
$results[] = "Unknown task: {$task}";
}
} catch (Throwable $e) {
$results[] = "Error ({$task}): " . $e->getMessage();
}
}
return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed'];
}
/**
* Detect whether the database contains sanitized sentinel password hashes.
* Returns true if any user has the MokoSuiteBackup sanitized placeholder hash.
*/
function detectSanitizedPasswords(array $data): array
{
$pdo = getDbConnection($data);
$prefix = getValidatedPrefix($data);
$sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?");
$stmt->execute([$sentinel]);
$count = (int) $stmt->fetchColumn();
return ['success' => true, 'detected' => $count > 0, 'count' => $count];
}
function actionProvision(array $data): array
{
$pdo = getDbConnection($data);
@@ -1048,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-steps" id="stepBar">
<div class="mr-step active" data-step="1"><span class="mr-num">1</span>Checks</div>
<div class="mr-step" data-step="2"><span class="mr-num">2</span>Extract</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Database</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Admin</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Provisioning</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Tables</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Database</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Configuration</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Admin</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Post-Restore</div>
<div class="mr-step" data-step="8"><span class="mr-num">8</span>Provisioning</div>
<div class="mr-step" data-step="9"><span class="mr-num">9</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
@@ -1107,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
</div>
<!-- Step 3: Database -->
<!-- Step 3: Table Conflict Resolution -->
<div class="mr-panel" id="panel3">
<h2>Table Conflict Resolution</h2>
<p class="mr-desc">Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.</p>
<div style="margin-bottom:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('replace')">All Replace</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('skip')">All Skip</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('merge')">All Merge</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="presetExceptUsers()">Everything except users</button>
</div>
<div class="mr-alert mr-alert-info" style="font-size:0.85rem">
<strong>Modes:</strong>
<strong>Replace</strong> = drop + recreate + insert (default).
<strong>Skip</strong> = ignore entirely.
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
</div>
<input type="hidden" id="tableResolutions" value="{}">
<div class="mr-status" id="tableScanStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnTablesContinue" onclick="goStep(4)">Continue to Database</button>
</div>
</div>
<!-- Step 4: Database -->
<div class="mr-panel" id="panel4">
<h2>Database Configuration</h2>
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
<div class="mr-row">
@@ -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-status" id="dbStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div>
</div>
<!-- Step 4: Site Configuration -->
<div class="mr-panel" id="panel4">
<!-- Step 5: Site Configuration -->
<div class="mr-panel" id="panel5">
<h2>Site Configuration</h2>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
@@ -1176,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="configStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div>
</div>
<!-- Step 5: Admin Password Reset -->
<div class="mr-panel" id="panel5">
<!-- Step 6: Admin Password Reset -->
<div class="mr-panel" id="panel6">
<h2>Super Admin Password</h2>
<p class="mr-desc">Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.</p>
<div class="mr-field">
@@ -1195,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="adminStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div>
</div>
</div>
<!-- Step 6: Client Provisioning -->
<div class="mr-panel" id="panel6">
<!-- Step 7: Post-Restore Actions -->
<div class="mr-panel" id="panel7">
<h2>Post-Restore Actions</h2>
<p class="mr-desc">Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.</p>
<div class="mr-alert mr-alert-warn" id="postRestoreSanitizedWarn" style="display:none">
<strong>Sanitized passwords detected!</strong> This backup contains placeholder password hashes that will prevent all users from logging in. The "Reset all user passwords" option below is strongly recommended.
</div>
<ul class="mr-provision-list" id="postRestoreList">
<li><input type="checkbox" class="post-restore-task" id="prResetPasswords" value="reset_passwords"><span>Reset all user passwords</span><span class="mr-provision-desc">Set to random temporary password and force reset on next login</span></li>
<li><input type="checkbox" class="post-restore-task" value="reset_hits"><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_versions"><span>Clear version history</span><span class="mr-provision-desc">Truncate the content version history table</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_sessions" checked><span>Clear sessions</span><span class="mr-provision-desc">Remove all active user sessions</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate cache tables and delete cache files</span></li>
</ul>
<div class="mr-status" id="postRestoreStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(8)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnPostRestore" onclick="runPostRestore()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 8: Client Provisioning -->
<div class="mr-panel" id="panel8">
<h2>Client Provisioning</h2>
<p class="mr-desc">Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.</p>
<ul class="mr-provision-list">
@@ -1219,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</ul>
<div class="mr-status" id="provisionStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(9)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 7: Complete -->
<div class="mr-panel" id="panel7">
<!-- Step 9: Complete -->
<div class="mr-panel" id="panel9">
<h2>Installation Complete</h2>
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
<div class="mr-alert mr-alert-success">
@@ -1299,7 +1786,9 @@ function goStep(n) {
else if (sn < n) s.classList.add('done');
});
if (n === 5) loadAdmins();
if (n === 3) scanTables();
if (n === 6) loadAdmins();
if (n === 7) checkSanitizedPasswords();
}
function setStatus(id, msg, type) {
@@ -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() {
return {
db_host: document.getElementById('dbHost').value,
@@ -1462,7 +2055,12 @@ async function runDatabase() {
log('Importing database...');
dbConfig = getDbParams();
const r = await post('database', dbConfig);
// Include table conflict resolution selections
var tableRes = document.getElementById('tableResolutions');
var dbParams = Object.assign({}, dbConfig, {
table_resolutions: tableRes ? tableRes.value : '{}'
});
const r = await post('database', dbParams);
document.getElementById('dbProgress').style.width = '100%';
setBtnLoading(btn, false);
@@ -1470,17 +2068,20 @@ async function runDatabase() {
if (r.success) {
setStatus('dbStatus', r.message, 'success');
log(r.message);
if (r.skipped && r.skipped > 0) {
log(' Skipped ' + r.skipped + ' statements due to conflict resolution');
}
if (r.errorList && r.errorList.length > 0) {
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
}
setTimeout(function() { goStep(4); }, 500);
setTimeout(function() { goStep(5); }, 500);
} else {
setStatus('dbStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 4
// Step 5
async function runConfig() {
const btn = document.getElementById('btnConfig');
setBtnLoading(btn, true);
@@ -1501,14 +2102,14 @@ async function runConfig() {
if (r.success) {
setStatus('configStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(5); }, 500);
setTimeout(function() { goStep(6); }, 500);
} else {
setStatus('configStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 5
// Step 6
async function loadAdmins() {
const sel = document.getElementById('adminSelect');
while (sel.firstChild) sel.removeChild(sel.firstChild);
@@ -1553,20 +2154,65 @@ async function runResetAdmin() {
if (r.success) {
setStatus('adminStatus', r.message, 'success');
log(r.message);
setTimeout(function() { goStep(6); }, 500);
setTimeout(function() { goStep(7); }, 500);
} else {
setStatus('adminStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 6
// Step 7: Post-Restore
async function checkSanitizedPasswords() {
log('Checking for sanitized password hashes...');
try {
const r = await post('detectSanitized', dbConfig);
if (r.success && r.detected) {
document.getElementById('postRestoreSanitizedWarn').style.display = '';
document.getElementById('prResetPasswords').checked = true;
log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords');
} else {
document.getElementById('postRestoreSanitizedWarn').style.display = 'none';
log('No sanitized passwords detected');
}
} catch (e) {
log('Could not check for sanitized passwords: ' + e.message);
}
}
async function runPostRestore() {
const btn = document.getElementById('btnPostRestore');
const tasks = [];
document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(8); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' post-restore task(s)...');
const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) });
const r = await post('postRestore', params);
setBtnLoading(btn, false);
if (r.success) {
setStatus('postRestoreStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(8); }, 500);
} else {
setStatus('postRestoreStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 8
async function runProvision() {
const btn = document.getElementById('btnProvision');
const tasks = [];
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); });
if (tasks.length === 0) { goStep(7); return; }
if (tasks.length === 0) { goStep(9); return; }
setBtnLoading(btn, true);
log('Running ' + tasks.length + ' provisioning tasks...');
@@ -1579,14 +2225,14 @@ async function runProvision() {
if (r.success) {
setStatus('provisionStatus', r.message, 'success');
r.results.forEach(function(msg) { log(' ' + msg); });
setTimeout(function() { goStep(7); }, 500);
setTimeout(function() { goStep(9); }, 500);
} else {
setStatus('provisionStatus', r.message, 'error');
log('FAILED: ' + r.message);
}
}
// Step 7
// Step 9
async function runCleanup() {
log('Cleaning up restore files...');
const r = await post('cleanup');
@@ -7,7 +7,7 @@
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @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.
*/
@@ -24,21 +24,21 @@ class PlaceholderResolver
* Supported placeholders and their descriptions (for documentation).
*/
public const PLACEHOLDERS = [
'[host]' => 'Server hostname',
'[date]' => 'Date as Ymd (e.g. 20260604)',
'[time]' => 'Time as His (e.g. 143025)',
'[datetime]' => 'Date and time as Ymd_His',
'[year]' => 'Four-digit year',
'[month]' => 'Two-digit month',
'[day]' => 'Two-digit day',
'[hour]' => 'Two-digit hour (24h)',
'[minute]' => 'Two-digit minute',
'[second]' => 'Two-digit second',
'[profile_id]' => 'Backup profile ID',
'[profile_name]' => 'Profile title (sanitized)',
'[site_name]' => 'Joomla site name (sanitized)',
'[type]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string',
'[HOST]' => 'Server hostname',
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
'[TIME]' => 'Time as His (e.g. 143025)',
'[DATETIME]' => 'Date and time as Ymd_His',
'[YEAR]' => 'Four-digit year',
'[MONTH]' => 'Two-digit month',
'[DAY]' => 'Two-digit day',
'[HOUR]' => 'Two-digit hour (24h)',
'[MINUTE]' => 'Two-digit minute',
'[SECOND]' => 'Two-digit second',
'[PROFILE_ID]' => 'Backup profile ID',
'[PROFILE_NAME]' => 'Profile title (sanitized)',
'[SITE_NAME]' => 'Joomla site name (sanitized)',
'[TYPE]' => 'Backup type (full, database, files, differential)',
'[RANDOM]' => 'Random 6-character hex string',
'[DEFAULT_DIR]' => 'Default backup directory',
'[HOME]' => 'Home directory of the PHP process owner',
];
@@ -62,21 +62,21 @@ class PlaceholderResolver
}
$this->replacements = [
'[host]' => $hostname,
'[date]' => $now->format('Ymd'),
'[time]' => $now->format('His'),
'[datetime]' => $now->format('Ymd_His'),
'[year]' => $now->format('Y'),
'[month]' => $now->format('m'),
'[day]' => $now->format('d'),
'[hour]' => $now->format('H'),
'[minute]' => $now->format('i'),
'[second]' => $now->format('s'),
'[profile_id]' => (string) ($profile->id ?? '0'),
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
'[type]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)),
'[HOST]' => $hostname,
'[DATE]' => $now->format('Ymd'),
'[TIME]' => $now->format('His'),
'[DATETIME]' => $now->format('Ymd_His'),
'[YEAR]' => $now->format('Y'),
'[MONTH]' => $now->format('m'),
'[DAY]' => $now->format('d'),
'[HOUR]' => $now->format('H'),
'[MINUTE]' => $now->format('i'),
'[SECOND]' => $now->format('s'),
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
'[TYPE]' => $profile->backup_type ?? 'full',
'[RANDOM]' => bin2hex(random_bytes(3)),
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
];
@@ -103,7 +103,7 @@ class PlaceholderResolver
*/
public function getHostname(): string
{
return $this->replacements['[host]'];
return $this->replacements['[HOST]'];
}
/**
@@ -111,7 +111,7 @@ class PlaceholderResolver
*/
public function getTag(): string
{
return $this->replacements['[datetime]'];
return $this->replacements['[DATETIME]'];
}
/**
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Event\Event;
class RestoreEngine
{
@@ -166,6 +167,9 @@ class RestoreEngine
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRestore(true, $recordId);
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
@@ -185,6 +189,9 @@ class RestoreEngine
$this->recursiveDelete($this->stagingDir);
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRestore(false, $recordId);
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
@@ -285,6 +292,26 @@ class RestoreEngine
@rmdir($dir);
}
/**
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterRestore(bool $success, int $recordId): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterRestore', [
'success' => $success,
'record_id' => $recordId,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the restore result, but log it
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -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);
}
}
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
use Joomla\Event\Event;
class SnapshotEngine
{
@@ -214,6 +215,9 @@ class SnapshotEngine
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
return [
'success' => true,
'message' => sprintf(
@@ -227,6 +231,9 @@ class SnapshotEngine
} catch (\Exception $e) {
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
return [
'success' => false,
'message' => 'Snapshot failed: ' . $e->getMessage(),
@@ -327,6 +334,27 @@ class SnapshotEngine
return $db->loadAssocList() ?: [];
}
/**
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
'success' => $success,
'snapshot_id' => $snapshotId,
'content_types' => $contentTypes,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the snapshot result, but log it
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Event\Event;
class SnapshotRestoreEngine
{
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
return [
'success' => true,
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
@@ -537,6 +544,9 @@ class SnapshotRestoreEngine
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
return [
'success' => true,
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
@@ -553,6 +563,9 @@ class SnapshotRestoreEngine
$this->log('FATAL: ' . $e->getMessage());
// Dispatch event for actionlog and other listeners
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
return [
'success' => false,
'message' => 'Selective restore failed: ' . $e->getMessage(),
@@ -561,6 +574,27 @@ class SnapshotRestoreEngine
}
}
/**
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
'success' => $success,
'snapshot_id' => $snapshotId,
'mode' => $mode,
]);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the restore result, but log it
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
}
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -81,9 +81,21 @@ class SteppedBackupEngine
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
}
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$archiveFormat = $profile->archive_format ?? 'zip';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
// The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
// supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
if ($archiveFormat !== 'zip') {
return [
'error' => true,
'message' => 'The stepped backup engine only supports ZIP format. '
. 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
];
}
$archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
@@ -52,15 +52,15 @@ class FolderPickerField extends FormField
$placeholders = [
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
'[host]' => $hostname,
'[site_name]' => $sanitizedSiteName ?: 'joomla',
'[profile_id]' => '1',
'[profile_name]' => 'default',
'[type]' => 'full',
'[year]' => date('Y'),
'[month]' => date('m'),
'[day]' => date('d'),
'[date]' => date('Ymd'),
'[HOST]' => $hostname,
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
'[PROFILE_ID]' => '1',
'[PROFILE_NAME]' => 'default',
'[TYPE]' => 'full',
'[YEAR]' => date('Y'),
'[MONTH]' => date('m'),
'[DAY]' => date('d'),
'[DATE]' => date('Ymd'),
];
$placeholdersJson = json_encode($placeholders);
@@ -96,51 +96,140 @@ class FolderPickerField extends FormField
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</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>
</button>
</div>
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
<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="[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="[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="[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="[TYPE]" title="Backup type">[TYPE]</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
{$statusDetail}
</small>
</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;">
<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.
</div>
<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-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>
</div>
<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">
<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>
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</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>[host]</code></td><td>Server hostname</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>[date]</code></td><td>Date (Ymd)</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>[month]</code></td><td>Two-digit month</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>[profile_id]</code></td><td>Backup profile ID</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>[type]</code></td><td>Backup type</td><td><code>full</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 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 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 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>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>[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 (01-31).</td><td><code>{$placeholders['[DAY]']}</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>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: full, database, files, or differential.</td><td><code>full</code></td></tr>
</tbody>
</table>
<h6>Recommended Paths</h6>
<ul class="list-unstyled">
<li><code>[HOME]/backups</code> Outside web root (recommended)</li>
<li><code>[HOME]/backups/[host]</code> Per-site subdirectory</li>
<li><code>[DEFAULT_DIR]</code> Inside web root (protected by .htaccess)</li>
</ul>
<h6 class="text-primary mt-3">Recommended Configurations</h6>
<table class="table table-sm">
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<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 class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -155,6 +244,56 @@ class FolderPickerField extends FormField
</div>
<script>
(function() {
/* Clickable placeholder insertion at cursor position */
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
/* Move cursor to after the inserted placeholder */
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
/* Trigger input event so status updates */
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
/* 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 btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
@@ -162,7 +301,7 @@ class FolderPickerField extends FormField
var input = document.getElementById(fieldId);
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) {
for (var key in placeholders) {
path = path.split(key).join(placeholders[key]);
@@ -253,8 +392,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() {
clearTimeout(checkTimer);
updateResolvedDisplay();
checkTimer = setTimeout(checkDirPermissions, 400);
});
@@ -368,6 +553,7 @@ class FolderPickerField extends FormField
// Run initial check on page load
setDefaultDirWarning();
updateResolvedDisplay();
checkDirPermissions();
})();
</script>
@@ -0,0 +1,78 @@
<?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
*
* Text field with clickable placeholder pills that insert at cursor position.
* Used for backup directory and archive name format fields.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
class PlaceholderTextField extends FormField
{
protected $type = 'PlaceholderText';
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');
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
$max = (int) ($this->element['maxlength'] ?? 512);
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
if (empty($placeholders)) {
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
}
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
. ' class="form-control" maxlength="' . $max . '"'
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
foreach ($placeholders as $ph) {
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
. htmlspecialchars($ph) . '</button>';
}
$html .= '</div>';
$html .= <<<JS
<script>
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
var target = document.getElementById(this.getAttribute('data-field'));
var ph = this.getAttribute('data-ph');
if (!target) return;
var start = target.selectionStart || 0;
var end = target.selectionEnd || 0;
var val = target.value;
target.value = val.substring(0, start) + ph + val.substring(end);
var newPos = start + ph.length;
target.setSelectionRange(newPos, newPos);
target.focus();
target.dispatchEvent(new Event('input', { bubbles: true }));
});
});
</script>
JS;
return $html;
}
}
@@ -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;
}
}
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
$query->select('a.*')
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
// Subquery: count of backup records per profile
$subQuery = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
$published = $this->getState('filter.published');
if (is_numeric($published)) {
@@ -272,6 +272,6 @@ HTACCESS;
*/
public static function logPathFromArchive(string $archivePath): string
{
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
return preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
}
}
@@ -128,6 +128,7 @@ class HtmlView extends BaseHtmlView
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
}
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
use Joomla\CMS\Toolbar\Toolbar;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
ToolbarHelper::save('profile.save');
}
if (!$isNew) {
$toolbar = Toolbar::getInstance();
$profileId = (int) $this->item->id;
// "Run Backup Now" button — links to backup start with CSRF token
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
->url($runUrl)
->icon('icon-play')
->buttonClass('btn btn-success');
}
// "View Backups" link button
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
->url($backupsUrl)
->icon('icon-database')
->buttonClass('btn btn-info');
}
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
}
}
@@ -191,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<div id="mokosuitebackup-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="mb-modal-title" style="margin:0 0 1rem;">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>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-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>
@@ -691,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Purge Backups Modal -->
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
<?php if ($canDelete) : ?>
<div id="mb-purge-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;">
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
</h4>
<button type="button" class="btn-close mb-purge-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
<div style="padding:1.5rem;">
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
<div class="mb-3">
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
</div>
<div id="mb-purge-count-wrapper" style="display:none;">
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
</div>
<div id="mb-purge-none-wrapper" style="display:none;">
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-purge-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
<span class="icon-trash" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<?php endif; ?>
<!-- Backup Comparison Modal -->
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
@@ -859,3 +902,114 @@ $listDirn = $this->escape($this->state->get('list.direction'));
});
})();
</script>
<?php if ($canDelete) : ?>
<script>
(function() {
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
var purgeCountTimer = null;
// Intercept Purge toolbar button to show the modal
document.addEventListener('DOMContentLoaded', function() {
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
if (purgeBtn) {
purgeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Reset modal state
document.getElementById('mb-purge-date').value = '';
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
document.getElementById('mb-purge-submit').disabled = true;
document.getElementById('mb-purge-modal').style.display = 'block';
return false;
}, true);
}
// Date change triggers count lookup with debounce
var dateInput = document.getElementById('mb-purge-date');
if (dateInput) {
dateInput.addEventListener('change', function() {
if (purgeCountTimer) clearTimeout(purgeCountTimer);
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
});
}
// Close modal
document.addEventListener('click', function(e) {
if (e.target.id === 'mb-purge-modal' || e.target.classList.contains('mb-purge-close')) {
document.getElementById('mb-purge-modal').style.display = 'none';
}
});
// Confirm on submit
var purgeForm = document.getElementById('mb-purge-form');
if (purgeForm) {
purgeForm.addEventListener('submit', function(e) {
var msg = document.getElementById('mb-purge-count-msg').textContent;
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
e.preventDefault();
}
});
}
});
function fetchPurgeCount() {
var dateVal = document.getElementById('mb-purge-date').value;
var countWrapper = document.getElementById('mb-purge-count-wrapper');
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
var countMsg = document.getElementById('mb-purge-count-msg');
var submitBtn = document.getElementById('mb-purge-submit');
if (!dateVal) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
return;
}
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
var form = new URLSearchParams();
form.append('task', 'ajax.countPurge');
form.append('date', dateVal);
form.append(PURGE_TOKEN, '1');
fetch(PURGE_AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
countMsg.textContent = data.message || 'Error';
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
} else if (data.count === 0) {
countWrapper.style.display = 'none';
noneWrapper.style.display = 'block';
submitBtn.disabled = true;
} else {
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
countMsg.textContent = text.replace('%d', data.count);
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = false;
}
})
.catch(function(err) {
countMsg.textContent = 'Error: ' + err.message;
countWrapper.style.display = 'block';
noneWrapper.style.display = 'none';
submitBtn.disabled = true;
});
}
})();
</script>
<?php endif; ?>
@@ -305,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
<div id="mokosuitebackup-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="mb-modal-title" style="margin:0 0 1rem;">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>Do not navigate away or close this window</strong> while the backup is running.
</div>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-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>
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Layout\LayoutHelper;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.multiselect');
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-5 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
</th>
<th scope="col" class="w-5">
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
</th>
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<td>
<?php echo $this->escape($item->backup_type); ?>
</td>
<td class="text-center">
<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'; ?>">
<?php echo (int) $item->backup_count; ?>
</span>
</a>
</td>
<td>
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
</td>
<td class="text-center">
<?php if ($item->published == 1) : ?>
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
class="btn btn-sm btn-outline-success"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
<span class="icon-play" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
</a>
<?php endif; ?>
</td>
<td>
<?php echo (int) $item->id; ?>
</td>
@@ -99,14 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</td>
<td>
<?php if ($item->status === 'complete' && $canManage) : ?>
<?php if (in_array('articles', $types)) : ?>
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
data-id="<?php echo (int) $item->id; ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
<span class="icon-search"></span>
</button>
<?php endif; ?>
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
data-id="<?php echo (int) $item->id; ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
<span class="icon-search"></span>
</button>
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
data-id="<?php echo (int) $item->id; ?>"
data-types="<?php echo $this->escape($item->content_types); ?>"
@@ -235,9 +233,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Browse Snapshot Articles Modal -->
<!-- Browse Snapshot Detail Modal -->
<div id="mb-snapshot-browse-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:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
@@ -251,25 +249,86 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
<div id="mb-browse-content" style="display:none;">
<div class="mb-2">
<label class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
</label>
<span class="text-muted ms-2" id="mb-browse-count"></span>
<!-- Bootstrap tabs -->
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
</button>
</li>
</ul>
<div class="tab-content pt-3" id="mb-browse-tabs-content">
<!-- Articles tab -->
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
<div class="mb-2">
<label class="form-check form-check-inline">
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
</label>
<span class="text-muted ms-2" id="mb-browse-count"></span>
</div>
<table class="table table-sm table-striped" id="mb-browse-table">
<thead>
<tr>
<th class="w-1"></th>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody"></tbody>
</table>
</div>
<!-- Categories tab -->
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
<table class="table table-sm table-striped" id="mb-browse-categories-table">
<thead>
<tr>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-categories-tbody"></tbody>
</table>
</div>
<!-- Modules tab -->
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
<table class="table table-sm table-striped" id="mb-browse-modules-table">
<thead>
<tr>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-modules-tbody"></tbody>
</table>
</div>
</div>
<table class="table table-sm table-striped" id="mb-browse-table">
<thead>
<tr>
<th class="w-1"></th>
<th>ID</th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
</tr>
</thead>
<tbody id="mb-browse-tbody"></tbody>
</table>
</div>
</div>
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
@@ -344,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', 'mb-rtype-' + type);
label.textContent = typeLabels[type] || type;
label.textContent = typeLabels[TYPE] || type;
div.appendChild(input);
div.appendChild(label);
@@ -388,9 +447,16 @@ $listDirn = $this->escape($this->state->get('list.direction'));
document.getElementById('mb-browse-restore-btn').disabled = true;
document.getElementById('mb-browse-select-all').checked = false;
// Reset to Articles tab
var firstTab = document.querySelector('#mb-tab-articles-btn');
if (firstTab && typeof bootstrap !== 'undefined') {
var tab = new bootstrap.Tab(firstTab);
tab.show();
}
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
// Fetch articles via AJAX
// Fetch snapshot content via AJAX
var token = <?php echo json_encode(Session::getFormToken()); ?>;
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
@@ -405,13 +471,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
return;
}
var tbody = document.getElementById('mb-browse-tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
data.articles.forEach(function(article) {
// --- Articles ---
var tbody = document.getElementById('mb-browse-tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
(data.articles || []).forEach(function(article) {
var tr = document.createElement('tr');
var tdCheck = document.createElement('td');
@@ -445,12 +512,84 @@ $listDirn = $this->escape($this->state->get('list.direction'));
tbody.appendChild(tr);
});
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
// --- Categories ---
var catTbody = document.getElementById('mb-browse-categories-tbody');
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
(data.categories || []).forEach(function(cat) {
var tr = document.createElement('tr');
var tdId = document.createElement('td');
tdId.textContent = cat.id;
tr.appendChild(tdId);
var tdTitle = document.createElement('td');
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
tr.appendChild(tdTitle);
var tdExt = document.createElement('td');
tdExt.textContent = cat.extension;
tr.appendChild(tdExt);
var tdState = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
tdState.appendChild(badge);
tr.appendChild(tdState);
var tdLevel = document.createElement('td');
tdLevel.textContent = cat.level;
tr.appendChild(tdLevel);
catTbody.appendChild(tr);
});
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
// --- Modules ---
var modTbody = document.getElementById('mb-browse-modules-tbody');
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
(data.modules || []).forEach(function(mod) {
var tr = document.createElement('tr');
var tdId = document.createElement('td');
tdId.textContent = mod.id;
tr.appendChild(tdId);
var tdTitle = document.createElement('td');
tdTitle.textContent = mod.title;
tr.appendChild(tdTitle);
var tdType = document.createElement('td');
tdType.textContent = mod.module;
tr.appendChild(tdType);
var tdPos = document.createElement('td');
tdPos.textContent = mod.position;
tr.appendChild(tdPos);
var tdState = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
tdState.appendChild(badge);
tr.appendChild(tdState);
modTbody.appendChild(tr);
});
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
document.getElementById('mb-browse-content').style.display = 'block';
})
.catch(function(err) {
document.getElementById('mb-browse-loading').style.display = 'none';
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
document.getElementById('mb-browse-error').style.display = 'block';
});
});
@@ -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.39.01</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,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
return [
'onContentAfterSave' => 'onContentAfterSave',
'onContentAfterDelete' => 'onContentAfterDelete',
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
];
}
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
);
}
/**
* Log when a backup is restored.
*/
public function onMokoSuiteBackupAfterRestore(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$recordId = $args['record_id'] ?? 0;
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
$this->addLog(
[
$messageKey,
'id' => $recordId,
'title' => 'Backup #' . $recordId,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.backup',
$this->getCurrentUserId()
);
}
/**
* Log when a content snapshot is created.
*/
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$snapshotId = $args['snapshot_id'] ?? 0;
$contentTypes = $args['content_types'] ?? [];
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
$this->addLog(
[
$messageKey,
'id' => $snapshotId,
'title' => 'Snapshot #' . $snapshotId,
'content_types' => implode(', ', $contentTypes),
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.snapshot',
$this->getCurrentUserId()
);
}
/**
* Log when a snapshot is restored.
*/
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$snapshotId = $args['snapshot_id'] ?? 0;
$mode = $args['mode'] ?? 'replace';
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
$this->addLog(
[
$messageKey,
'id' => $snapshotId,
'title' => 'Snapshot #' . $snapshotId,
'mode' => $mode,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokosuitebackup.snapshot',
$this->getCurrentUserId()
);
}
/**
* Write an action log entry.
*/
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+2 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.35.03</version>
<version>01.39.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -28,6 +28,7 @@
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
<file type="module" id="mod_mokosuitebackup_cpanel" client="administrator">mod_mokosuitebackup_cpanel.zip</file>
</files>
<languages>