Compare commits

..

48 Commits

Author SHA1 Message Date
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
gitea-actions[bot] fe76f81b47 chore: promote changelog [Unreleased] → [01.35.03] 2026-06-23 13:53:26 +00:00
gitea-actions[bot] 18127454b5 chore(release): build 01.35.03 [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-23 13:53:23 +00:00
jmiller 7826c315b1 Merge pull request 'feat: SFTP key file upload, auth type dropdown, security hardening' (#96) from fix/sftp-key-upload into main 2026-06-23 13:53:03 +00:00
gitea-actions[bot] e329dbd99b chore(version): pre-release bump to 01.35.03-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 20s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m33s
2026-06-23 13:52:11 +00:00
Jonathan Miller d6b3e8cff0 feat: SFTP key file upload, auth type dropdown, security hardening
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
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 23s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Universal: Build & Release / Promote to RC (pull_request) Failing after 11s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 34s
SFTP UX improvements:
- SshKeyField: file upload button (FileReader → base64 → hidden field),
  key never displayed as readable text, __KEEP_EXISTING__ sentinel
  preserves DB value on re-save without re-uploading
- Auth type dropdown: password / key file / key file + passphrase
  with conditional field visibility via showon
- Required field markers on host, username, path, password
- Remove insecure FTP option from remote storage dropdown

Security:
- Private key stored base64-encoded in database
- SftpUploader decodes base64 before writing temp file
- ProfileTable::store() handles sentinel to prevent key leakage
- Key content never rendered in HTML form output
2026-06-23 08:51:49 -05:00
gitea-actions[bot] 80c97620a5 chore: promote changelog [Unreleased] → [01.35.01] 2026-06-23 13:33:26 +00:00
gitea-actions[bot] 33d852bacf chore(release): build 01.35.01 [skip ci]
Publish to Composer / Publish Package (release) Failing after 3s
2026-06-23 13:33:24 +00:00
jmiller 8be0500913 Merge pull request 'fix: SFTP fields not showing when SFTP selected' (#95) from fix/sftp-showon into main 2026-06-23 13:32:53 +00:00
39 changed files with 1095 additions and 99 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.35.02
# VERSION: 01.38.02
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+17 -16
View File
@@ -1,26 +1,27 @@
# Changelog
## [Unreleased]
## [01.35.00] --- 2026-06-23
## [01.38.01] --- 2026-06-23
## [01.35.00] --- 2026-06-23
## [01.38.01] --- 2026-06-23
## [01.38.00] --- 2026-06-23
## [01.38.00] --- 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
- Standalone restore script mode — restore.php as separate file that scans for backup ZIPs in its directory (#107)
- MokoRestore profile option: None / Wrapped / Standalone
- Standalone mode uploads restore.php alongside backup to remote storage
## [01.34.00] --- 2026-06-23
## [01.37.00] --- 2026-06-23
## [01.34.00] --- 2026-06-23
## [01.37.00] --- 2026-06-23
### Added
- Dashboard: snapshot widget, backup trend chart (30 days), and storage breakdown by profile (#61)
## [01.33.00] --- 2026-06-23
## [01.33.00] --- 2026-06-23
### Added
- Backup comparison: select two backups to view side-by-side size, file count, and duration differences (#64)
- Selective article restore: browse articles inside a snapshot and restore individual items (#58)
- Archive browser: view files inside a backup without extracting (#59)
- Run Backup button on profiles list and edit views with backup count badges (#100, #101)
- Snapshot detail view with tabbed browser for articles, categories, and modules (#104)
- "Do not navigate away" warning in backup and restore progress modals (#108)
- Joomla Action Logs integration for restore, snapshot, and snapshot restore events (#110)
- 8 comprehensive testing issues created (#111-#118)
- Manual purge feature issue (#119)
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.35.02 -->
<!-- VERSION: 01.38.02 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -72,23 +72,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]"
maxlength="512"
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"
@@ -159,7 +161,6 @@
default="none"
>
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
@@ -203,23 +204,34 @@
maxlength="255"
showon="remote_storage:sftp"
/>
<field
name="sftp_auth_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
default="key"
showon="remote_storage:sftp"
>
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
</field>
<field
name="sftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
maxlength="255"
showon="remote_storage:sftp"
showon="remote_storage:sftp[AND]sftp_auth_type:password"
/>
<field
name="sftp_key_data"
type="textarea"
type="SshKey"
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
rows="6"
cols="60"
filter="raw"
showon="remote_storage:sftp"
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="sftp_passphrase"
@@ -227,7 +239,7 @@
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
maxlength="255"
showon="remote_storage:sftp"
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
/>
<field
name="sftp_path"
@@ -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"
@@ -127,8 +133,11 @@ 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_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_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)"
; Exclusion filter fields
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
@@ -256,7 +265,17 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Paste the contents of your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
@@ -405,6 +424,20 @@ 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."
; 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."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.35.02</version>
<version>01.38.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
`sftp_key_data` MEDIUMTEXT,
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
@@ -38,7 +39,7 @@ 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',
`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,
@@ -0,0 +1,4 @@
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
@@ -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';
@@ -622,28 +622,67 @@ 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),
]);
}
@@ -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');
@@ -237,26 +237,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 +283,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);
@@ -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.
*
@@ -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;
@@ -141,7 +141,15 @@ class SftpUploader implements RemoteUploaderInterface
$tmpDir = sys_get_temp_dir();
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
if (file_put_contents($keyFile, $this->keyData) === false) {
/* Key is stored base64-encoded in the database — decode before writing */
$keyContent = base64_decode($this->keyData, true);
if ($keyContent === false) {
/* Fallback: might be raw PEM (legacy or paste) */
$keyContent = $this->keyData;
}
if (file_put_contents($keyFile, $keyContent) === false) {
throw new \RuntimeException('Cannot write temporary SSH key file');
}
@@ -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;
@@ -100,12 +100,25 @@ class FolderPickerField extends FormField
<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.
@@ -155,6 +168,26 @@ 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 }));
});
});
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
@@ -253,8 +286,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 = 'Resolves to: ';
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 +447,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,109 @@
<?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
*
* Custom field for SSH private key input.
* Supports both file upload (via FileReader JS) and paste-in textarea.
* The key content is stored in the database, not as a file on disk.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class SshKeyField extends FormField
{
protected $type = 'SshKey';
protected function getInput(): string
{
$value = $this->value ?? '';
$id = $this->id;
$name = $this->name;
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
/* Status badge */
if ($hasKey) {
$html .= '<span class="badge bg-success me-2">'
. '<span class="icon-lock" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
. '</span>';
}
/* File upload button */
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
$html .= '</label>';
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
if ($hasKey) {
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
. '<span class="icon-times" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
. '</button>';
}
/* Hidden field — key data is NEVER rendered as visible text.
On existing keys, we submit a sentinel value to preserve the DB value
unless a new file is uploaded or clear is clicked. */
if ($hasKey) {
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="__KEEP_EXISTING__">';
} else {
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="">';
}
$html .= '</div>';
$html .= $this->getScript();
return $html;
}
private function getScript(): string
{
return <<<'JS'
<script>
function mokoSshKeyFileSelected(fieldId, input) {
if (!input.files || !input.files[0]) return;
var file = input.files[0];
var reader = new FileReader();
reader.onload = function(e) {
/* Base64 encode the key before storing in the hidden field */
var content = e.target.result;
var encoded = btoa(content);
document.getElementById(fieldId).value = encoded;
var status = document.getElementById(fieldId + '-status');
if (status) status.textContent = file.name + ' uploaded';
};
reader.readAsText(file);
}
function mokoSshKeyClear(fieldId) {
document.getElementById(fieldId).value = '';
var status = document.getElementById(fieldId + '-status');
if (status) status.textContent = 'Key removed';
var fileInput = document.getElementById(fieldId + '-file');
if (fileInput) fileInput.value = '';
}
</script>
JS;
}
}
@@ -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)) {
@@ -25,6 +25,23 @@ class ProfileTable extends Table
public function store($updateNulls = true): bool
{
/* Handle SSH key sentinel — when __KEEP_EXISTING__ is submitted,
preserve the current DB value instead of overwriting with the sentinel.
This prevents the key from being exposed in the form HTML. */
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
if ($this->id) {
$db = $this->getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('sftp_key_data'))
->from($db->quoteName($this->_tbl))
->where($db->quoteName('id') . ' = ' . (int) $this->id);
$db->setQuery($query);
$this->sftp_key_data = $db->loadResult() ?: '';
} else {
$this->sftp_key_data = '';
}
}
$result = parent::store($updateNulls);
if ($result && !empty($this->backup_dir)) {
@@ -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>
@@ -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;">
@@ -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';
});
});
@@ -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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</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.02</version>
<version>01.38.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.35.02</version>
<version>01.38.02</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>