Compare commits

...

117 Commits

Author SHA1 Message Date
gitea-actions[bot] 9db7331a72 chore(version): pre-release bump to 01.28.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-21 23:58:07 +00:00
gitea-actions[bot] 32931c1e37 chore(version): auto-bump patch 01.27.04-dev [skip ci] 2026-06-21 23:57:56 +00:00
gitea-actions[bot] c299798542 chore: promote changelog [Unreleased] → [01.27.03]
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
Publish to Composer / Publish Package (release) Failing after 28s
2026-06-21 23:57:36 +00:00
gitea-actions[bot] 612dc4acd5 chore(release): build 01.27.03 [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-21 23:57:32 +00:00
jmiller cdb54f6a3e Merge pull request 'fix: Remove orphaned root-level webservices plugin files' (#87) from fix/remove-orphaned-root-files into main 2026-06-21 23:56:04 +00:00
jmiller 6fbc91527e chore: remove unused Makefile - builds handled by CI auto-release 2026-06-21 23:55:32 +00:00
Jonathan Miller 57bfb37be1 fix: remove orphaned root-level webservices plugin 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: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 5s
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
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 8s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 26s
The root mokosuitebackup.xml and mokosuitebackup.php are stale
copies from before the code was restructured into source/packages/.
The webservices plugin lives at source/packages/plg_webservices_mokosuitebackup/
with its own manifest and src/ directory.

The root manifest still referenced <folder>src</folder> but that
directory was removed in PR #82, causing Joomla installer to fail
with "File does not exist [ROOT][TMP]/.../mokosuitebackup/src".
2026-06-21 18:54:45 -05:00
Jonathan Miller 3328d7cf19 feat: backup type filter + path traversal protection (#68, #72)
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) Successful in 25s
Universal: Build & Release / Promote to RC (pull_request) Successful in 28s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
#68: Add backup type filter dropdown to backups list view
- filter_backups.xml: full/database/files/differential options
- BackupsModel: backup_type filter in getListQuery()
- Language string: COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL

#72: Path traversal protection in RestoreEngine and MokoRestore
- RestoreEngine::extractArchive(): validate ZIP entries before extractTo()
- RestoreEngine::extractTarGz(): validate PharData entries before extractTo()
- MokoRestore standalone script: same validation in generated PHP code
- Rejects entries containing ../ or starting with / or \

Closes #68, closes #72
2026-06-21 18:50:07 -05:00
jmiller c410c02487 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 23:38:38 +00:00
jmiller 93879c8118 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 23:38:37 +00:00
jmiller e4329c9fc6 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 23:38:34 +00:00
gitea-actions[bot] 0fa58daa12 chore: promote changelog [Unreleased] → [01.27.00]
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 21s
Publish to Composer / Publish Package (release) Failing after 1m46s
2026-06-21 23:24:27 +00:00
gitea-actions[bot] f8591ed15c chore(release): build 01.27.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 48s
2026-06-21 23:24:24 +00:00
jmiller cbc7004d18 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 23:24:11 +00:00
jmiller a33a585b98 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 23:24:10 +00:00
jmiller 2573ba8599 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 23:24:09 +00:00
jmiller f0d506bbb1 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 23:24:06 +00:00
jmiller a26343a76e Merge pull request 'fix: Remaining audit findings — OOM, security, error handling (#81)' (#85) from fix/audit-remaining into main 2026-06-21 23:17:56 +00:00
Jonathan Miller 9990240d2d fix: remaining audit findings — OOM, security, error handling (#81)
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 5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 22s
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 2m32s
CRITICAL:
- #73: S3Uploader now streams file via CURLOPT_PUT/INFILE instead of
  loading entire file into RAM with file_get_contents
- #74: DatabaseDumper gains dumpToFile() that streams SQL to disk;
  BackupEngine uses addFile() instead of addFromString() to avoid
  holding the entire dump in memory
- #75: AkeebaImporter removes unserialize() — only uses json_decode,
  skips legacy serialized filter data to prevent object injection

MEDIUM (also fixed):
- BackupEngine: $archiveName initialized before try block (prevents
  undefined variable in catch)
- BackupEngine: plaintext archive deleted on encryption failure
- BackupEngine: temp SQL file cleaned up in both success and failure
- BackupEngine: createArchiver() throws on unknown format instead of
  silently falling back to ZIP
- TarGzArchiver: intermediate .tar cleaned up in finally block

Closes #73, closes #74, closes #75
Ref #81
2026-06-21 18:16:46 -05:00
jmiller 418db394a4 Merge pull request 'chore: remove automation directory' (#82) from fix/remove-automation into main 2026-06-21 23:10:42 +00:00
jmiller d939d8c9d7 Merge pull request 'fix: Critical and high audit findings (#81)' (#83) from fix/audit-critical-high into main 2026-06-21 23:10:19 +00:00
gitea-actions[bot] 6383e9b111 chore(version): pre-release bump to 01.27.03-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 21s
Publish to Composer / Publish Package (release) Failing after 44s
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 5s
2026-06-21 23:10:10 +00:00
Jonathan Miller 2395a4eabc fix: critical and high audit findings (#81)
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 5s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 17s
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 7m48s
Fixes all critical and high severity issues from the codebase audit:

CRITICAL:
- #71: RestoreCommand passed wrong args to RestoreEngine (filepath
  instead of record ID) — CLI restore was completely broken
- #72: JpaUnarchiver path traversal — added traversal rejection and
  realpath boundary check to prevent writes outside staging dir
- #77: RestoreEngine staging path sanitized — $record->tag stripped
  of non-alphanumeric characters

HIGH:
- #75: (noted, AkeebaImporter unserialize needs separate refactor)
- #76: BackupTable now deletes DB row before file — prevents data
  loss if DB delete fails
- #78: API profiles endpoint now masks sensitive fields (passwords,
  keys, tokens) with '***'
- #79: Webcron handler adds return after sendJsonResponse — prevents
  execution falling through on non-terminal close()
- #80: BackupModel/ProfileModel loadFormData() now casts array to
  object — prevents TypeError on PHP 8.x form state restore

PREFLIGHT HARDENING:
- PreflightCheck::run() wrapped in try-catch for DB exceptions
- mkdir() failure now includes actual error reason
- Unresolved placeholders generate a warning instead of silent return

Closes #71, closes #76, closes #77, closes #78, closes #79, closes #80
Ref #72, ref #81
2026-06-21 18:08:58 -05:00
Jonathan Miller 1ec8ec8f6d chore: remove automation directory
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) Successful in 20s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 10s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
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 52s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
2026-06-21 18:03:55 -05:00
jmiller 8df630c529 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-21 22:56:04 +00:00
jmiller 5c8503e79e chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 22:56:02 +00:00
jmiller 3a087d7859 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 22:56:01 +00:00
jmiller 58d3b812a7 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-21 22:55:58 +00:00
gitea-actions[bot] ac3727f22f chore: promote changelog [Unreleased] → [01.27.00]
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 14s
2026-06-21 22:54:50 +00:00
gitea-actions[bot] 43a4e552ce chore(release): build 01.27.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 6s
2026-06-21 22:54:47 +00:00
jmiller a532e639ea Merge pull request 'feat: Pre-flight checks before backup starts (#67)' (#70) from feature/67-preflight-checks into main 2026-06-21 22:54:34 +00:00
gitea-actions[bot] f099ad8fe9 chore(version): auto-bump patch 01.26.02-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
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 2m35s
2026-06-21 22:54:29 +00:00
Jonathan Miller dbed0d0da7 fix: address PR review — remove dead code, consistent warnings key
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 / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 35s
- Remove dead checkRequiredExtensions() method (superseded by PreflightCheck)
- Add 'warnings' key to ALL return paths in BackupEngine::run() and
  SteppedBackupEngine::init() to prevent undefined key access on PHP 8.x
- Include preflight warnings in success, failure, and early-exit returns
2026-06-21 17:54:11 -05:00
gitea-actions[bot] 617c103055 chore(version): auto-bump patch 01.26.01-dev [skip ci] 2026-06-21 22:47:28 +00:00
Jonathan Miller edb202071c feat: add pre-flight checks before backup starts (#67)
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Validate backup prerequisites before creating any record, catching
common issues early with clear messages instead of failing mid-backup.

Pre-flight checks:
- Required PHP extensions (zip, pdo, pdo_mysql, mbstring, curl)
- Backup directory exists and is writable
- Sufficient disk space (last backup size + 20% buffer, skipped if
  no previous backup exists)
- No other backup already running for this profile
- Excluded tables exist in database (warns on missing)
- Remote storage credentials minimally configured (FTP/S3/GDrive)

Errors block the backup; warnings are logged and displayed but allow
the backup to proceed. Integrated into both BackupEngine::run() and
SteppedBackupEngine::init() before any record is inserted.

UI: AJAX init response includes warnings array, displayed in the
stepped backup progress modal.

Closes #67
2026-06-21 17:47:13 -05:00
gitea-actions[bot] c5fa755006 chore: promote changelog [Unreleased] → [01.26.00]
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
2026-06-21 22:36:24 +00:00
gitea-actions[bot] 92e94ddc17 chore(release): build 01.26.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-21 22:36:22 +00:00
jmiller 0f88aa0055 Merge pull request 'feat: Content snapshots, restore UI, and config hardening (v01.25.00)' (#69) from feature/47-backup-status-helper into main 2026-06-21 22:34:53 +00:00
Jonathan Miller 0a4f896f31 Merge remote-tracking branch 'origin/feature/47-backup-status-helper' into feature/47-backup-status-helper
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 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 12s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 15s
2026-06-21 17:32:53 -05:00
Jonathan Miller 8d67dfbc42 Merge remote-tracking branch 'origin/main' into feature/47-backup-status-helper
# Conflicts:
#	.mokogitea/workflows/issue-branch.yml
#	.mokogitea/workflows/pr-check.yml
#	README.md
#	mokosuitebackup.xml
#	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-21 17:29:58 -05:00
gitea-actions[bot] c2a88a898d chore(version): auto-bump patch 01.25.02-dev [skip ci] 2026-06-21 22:22:40 +00:00
Jonathan Miller d5421738b7 fix: address PR review findings — error handling and safety
Generic: Project CI / Tests (pull_request) Blocked by required conditions
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 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Lint & Validate (pull_request) Successful in 44s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 51s
Fixes from code review and silent failure audit:

- SnapshotRestoreEngine: catch only duplicate key errors (MySQL 1062)
  in merge mode, re-throw all other exceptions instead of swallowing
- SnapshotRestoreEngine: add json_last_error() check for better error
  messages on corrupt snapshot files
- SnapshotRestoreEngine: log warnings when set_time_limit/ini_set fail
- SnapshotEngine: use strlen($json) instead of filesize() to avoid
  race conditions; catch \Exception instead of \Throwable
- SnapshotsController: remove @unlink suppression, add try-catch
  around delete loop with partial failure reporting
- script.php: add user-facing warning when webcron secret generation
  fails (was silently swallowed, inconsistent with other catch blocks)
2026-06-21 17:21:55 -05:00
jmiller 1962e39032 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:03:24 +00:00
Jonathan Miller 658ed77090 Merge remote-tracking branch 'origin/feature/47-backup-status-helper' into feature/47-backup-status-helper 2026-06-21 16:59:54 -05:00
Jonathan Miller 3bd103f0af chore: normalize CRLF in workflow files 2026-06-21 16:59:54 -05:00
Jonathan Miller 854383a899 fix: scope module DELETE to snapshot IDs in replace mode
The truncateFiltered() method ran unfiltered DELETE FROM #__modules
in replace mode, which would wipe ALL site modules (admin toolbar,
login, menus) — not just the ones in the snapshot. Now scoped to
only delete modules whose IDs exist in the snapshot data.

Also scopes #__modules_menu delete to snapshot module IDs, and adds
defense-in-depth validation of restore_mode in the controller.
2026-06-21 15:50:41 -05:00
gitea-actions[bot] 43c73d20e8 chore(version): auto-bump patch 01.25.01-dev [skip ci] 2026-06-21 20:30:09 +00:00
Jonathan Miller ef31713029 feat: content snapshots, restore UI, and config hardening (v01.25.00)
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Add content snapshot system for lightweight article/category/module
versioning independent of full backups. Snapshots store as JSON files
with replace or merge restore modes, wrapped in DB transactions.

- SnapshotEngine: dumps articles, categories, modules + related tables
  (workflow_associations, tag maps, frontpage) to JSON
- SnapshotRestoreEngine: replace (clean slate) or merge (upsert) mode
- Full MVC: controller, models, view, template with create/restore modals
- New ACL permission: mokosuitebackup.snapshot.manage
- Submenu entry with camera icon, upgrade SQL for snapshots table

Improve full-site restore UI with confirmation modal offering options
for files, database, preserve config, and encryption password.

Config improvements:
- WebcronSecretField: CSPRNG generator, strength meter, rejects weak
  patterns (password, admin, secret), enforces min 16 chars
- IpWhitelistField: table-based management, current IP detection with
  one-click "Add my IP" button
- Default profile shows "Title (#ID)" format
- Default backup dir uses [DEFAULT_DIR] placeholder
- Install script generates random 32-char webcron secret
- Dashboard quick actions: full-width dropdown with button below
2026-06-21 15:25:53 -05:00
jmiller 4c1615e337 chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:06:03 +00:00
jmiller 26a13ef692 chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:35:25 +00:00
jmiller f55836df0d chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:29:19 +00:00
jmiller 680e242465 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:29:18 +00:00
jmiller e2b12ef313 ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-21 00:15:06 +00:00
jmiller d1ca1d8e48 ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-21 00:14:37 +00:00
jmiller b2f8ae8b1f ci: sync ci-joomla.yml from Template-Joomla [skip ci] 2026-06-21 00:14:13 +00:00
jmiller 3d84e8079f chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-20 23:47:05 +00:00
jmiller cb68071155 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 23:47:04 +00:00
jmiller eeba315a0d chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 23:47:04 +00:00
jmiller 91bc675ea7 chore: sync gitleaks.yml from Template-Generic [skip ci] 2026-06-20 23:47:03 +00:00
jmiller 1b042311fb chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 23:47:02 +00:00
jmiller b4927163a6 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-20 23:47:01 +00:00
gitea-actions[bot] 571c0a7143 chore: promote changelog [Unreleased] → [01.25.00]
Universal: Auto Version Bump / Version Bump (push) Successful in 8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 8s
2026-06-20 23:31:25 +00:00
gitea-actions[bot] 2d7359cefd chore(release): build 01.25.00 [skip ci] 2026-06-20 23:31:22 +00:00
jmiller c828c46a4b Merge pull request 'feat: add BackupStatusHelper for bridge integration' (#53) from feature/47-backup-status-helper into main 2026-06-20 23:31:11 +00:00
gitea-actions[bot] 38a98e5ea3 chore(version): auto-bump patch 01.24.01-dev [skip ci]
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) Failing after 2s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 4s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 14s
2026-06-20 23:31:10 +00:00
Jonathan Miller a5a2f48e7c feat: add BackupStatusHelper for bridge integration (#47)
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 13s
Generic: Project CI / Lint & Validate (pull_request) Successful in 32s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 35s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Static helper class for external consumers (bridge plugins, MCP servers)
to query backup status without bootstrapping the full component.

Methods:
- isInstalled(): check if MokoSuiteBackup is installed and enabled
- getLatestRecord(): get the most recent completed/failed backup
- getStatusSummary(): full heartbeat payload with latest status,
  all-time/7-day totals, and consecutive success streak
2026-06-20 18:30:54 -05:00
jmiller 70f25f6e79 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-20 22:30:30 +00:00
jmiller 26dd5cf5c2 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-20 22:30:29 +00:00
jmiller b754542254 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-20 22:30:28 +00:00
jmiller 3942fa4661 chore: sync cleanup.yml from Template-Generic [skip ci] 2026-06-20 22:30:28 +00:00
jmiller 0764697086 ci: sync security-audit.yml from Template-Joomla [skip ci] 2026-06-20 22:26:32 +00:00
jmiller f7767b76dd ci: sync repo-health.yml from Template-Joomla [skip ci] 2026-06-20 22:26:04 +00:00
jmiller 37c003a97a ci: sync rc-revert.yml from Template-Joomla [skip ci] 2026-06-20 22:25:54 +00:00
jmiller 3c5460559b ci: sync pr-check.yml from Template-Joomla [skip ci] 2026-06-20 22:24:48 +00:00
jmiller e433314e42 ci: sync issue-branch.yml from Template-Joomla [skip ci] 2026-06-20 22:22:22 +00:00
jmiller db3beae7d3 ci: sync cleanup.yml from Template-Joomla [skip ci] 2026-06-20 22:15:38 +00:00
jmiller 734a5326e5 chore: sync ci-generic.yml from Template-Generic [skip ci] 2026-06-20 21:35:49 +00:00
jmiller 6c48a6a777 ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 21:34:03 +00:00
jmiller 4e4e608f89 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 21:31:35 +00:00
jmiller 2c17b07835 ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 21:28:11 +00:00
jmiller ea17d81e5e ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 21:26:58 +00:00
jmiller 58080a5ad7 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-20 20:54:09 +00:00
jmiller e8f054ddae chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-20 20:54:08 +00:00
jmiller 21bcf479cc ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 20:35:06 +00:00
jmiller 695b9e0be5 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 20:32:53 +00:00
jmiller 8fc7616d4d ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 20:31:55 +00:00
jmiller afe766302a ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 20:31:01 +00:00
jmiller 1a30dd7b98 ci: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-20 19:59:10 +00:00
jmiller 446296e0c8 ci: sync ci-generic.yml from Template-Joomla [skip ci] 2026-06-20 19:06:01 +00:00
jmiller 8f7c255c22 ci: sync cascade-dev.yml from Template-Joomla [skip ci] 2026-06-20 19:03:19 +00:00
jmiller db328cd7e1 ci: sync branch-cleanup.yml from Template-Joomla [skip ci] 2026-06-20 19:02:46 +00:00
jmiller 6fe5a003ea ci: sync auto-release.yml from Template-Joomla [skip ci] 2026-06-20 19:01:05 +00:00
jmiller 728e55c721 ci: sync auto-bump.yml from Template-Joomla [skip ci] 2026-06-20 18:53:51 +00:00
jmiller 2554ae98d0 ci: sync pre-release workflow from Template-Joomla
Generic: Project CI / Lint & Validate (push) Successful in 8s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 18:49:31 +00:00
jmiller 77a3b5f91e ci: add Joomla metadata validation workflow for PRs
Generic: Project CI / Lint & Validate (push) Successful in 13s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 18:39:09 +00:00
jmiller 3d640d1fa9 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Project CI / Lint & Validate (push) Successful in 38s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 17:17:05 +00:00
jmiller 7f89abf809 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Project CI / Lint & Validate (push) Successful in 10s
Generic: Project CI / Tests (push) Has been cancelled
2026-06-20 17:17:04 +00:00
jmiller c0b40d5a7e fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 11s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:17:03 +00:00
jmiller 8b367c3983 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 36s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:17:02 +00:00
jmiller 7a92c14de6 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Successful in 10s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:17:02 +00:00
jmiller b70d7f6b80 fix: rename moko-platform to mokocli + changelog promotion in workflows
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Project CI / Lint & Validate (push) Successful in 36s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 17:17:00 +00:00
gitea-actions[bot] 09f964b427 chore(release): build 01.24.00 [skip ci] 2026-06-20 16:38:03 +00:00
jmiller 0b1db735ce Merge pull request 'refactor: remove leftover src/ directory' (#52) from fix/remove-old-src-v2 into main
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 37s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-20 16:00:23 +00:00
Jonathan Miller 77270cceab refactor: remove leftover src/ directory (source/ is canonical)
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Project CI / Lint & Validate (pull_request) Successful in 43s
Branch Cleanup / Delete merged branch (pull_request) Failing after 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Failing after 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 8s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-20 10:58:35 -05:00
gitea-actions[bot] 291f09223d chore(release): build 01.24.00 [skip ci] 2026-06-19 07:14:55 +00:00
jmiller 674fd0f6b9 Merge pull request 'fix: remove deprecated .mokogitea/manifest.xml' (#50) from fix into main
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Project CI / Lint & Validate (push) Successful in 13s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-19 07:08:48 +00:00
Jonathan Miller 0d2e4b0c01 fix: remove deprecated .mokogitea/manifest.xml — metadata managed via API
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Project CI / Lint & Validate (pull_request) Successful in 19s
Branch Cleanup / Delete merged branch (pull_request) Failing after 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 8s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-19 02:04:25 -05:00
gitea-actions[bot] 64c6ef6d7e chore(release): build 01.23.00 [skip ci] 2026-06-18 16:07:30 +00:00
jmiller b2630fbc87 Merge pull request 'feat: per-profile retention, ntfy notifications, extension checks' (#46) from dev into main
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Project CI / Lint & Validate (push) Successful in 32s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
2026-06-18 16:07:20 +00:00
gitea-actions[bot] 2e7e49fa60 chore(version): pre-release bump to 01.22.10-dev [skip ci]
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 11s
2026-06-18 15:59:59 +00:00
Jonathan Miller 55954ba081 fix: remaining review items — prefix in trailing SQL, dead code, indent
Generic: Repo Health / Access control (push) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 7s
Generic: Project CI / Lint & Validate (push) Successful in 42s
Generic: Project CI / Lint & Validate (pull_request) Successful in 42s
Universal: PR Check / Validate PR (pull_request) Failing after 38s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 44s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- DatabaseImporter: apply #__ prefix replacement on trailing statement
  (was missing for SQL not terminated by semicolon)
- SteppedBackupEngine: remove unused DatabaseDumper instantiation
- SteppedBackupEngine: fix misaligned indentation in stepDatabase()
2026-06-18 10:59:47 -05:00
gitea-actions[bot] 7ecc855e40 chore(version): pre-release bump to 01.22.09-dev [skip ci] 2026-06-18 15:56:16 +00:00
Jonathan Miller a4c03d0032 fix: critical review — infinite recursion, SQL injection, FK prefix
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 7s
Generic: Project CI / Lint & Validate (push) Successful in 31s
Generic: Project CI / Lint & Validate (pull_request) Successful in 31s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Critical:
- Fix infinite recursion in getValidatedPrefix() — was calling itself
  instead of extracting from $data array
- Fix SQL injection in actionResetAdmin() — prefix not validated,
  now uses getValidatedPrefix()

High:
- Fix prefix abstraction to cover FK REFERENCES — str_replace now
  targets backtick+prefix pattern to catch all table references in
  CREATE TABLE output, not just the current table name

Medium:
- Security gate file write check — skip verification gracefully if
  file cannot be written (don't lock user out)
- Stepped notification catch \Throwable instead of \Exception
2026-06-18 10:56:02 -05:00
gitea-actions[bot] 682538e4de chore(version): pre-release bump to 01.22.08-dev [skip ci] 2026-06-18 15:42:29 +00:00
Jonathan Miller b2874f32f2 feat: abstract DB prefix, stepped checksum, restore security gate
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Auto Version Bump / Version Bump (push) Successful in 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 6s
Generic: Project CI / Lint & Validate (pull_request) Successful in 32s
Generic: Project CI / Lint & Validate (push) Successful in 32s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 35s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Database prefix abstraction:
- DatabaseDumper uses #__ placeholder instead of live prefix in all
  SQL output (DROP TABLE, CREATE TABLE, INSERT INTO)
- SteppedBackupEngine::dumpSingleTable() same #__ replacement
- DatabaseImporter replaces #__ with current site prefix on import
- MokoRestore replaces #__ with user-specified prefix on import
- Backups are now portable across sites with different prefixes

Stepped backup checksum:
- completeRecord() now computes and stores SHA-256 checksum

MokoRestore security gate:
- Writes .mokorestore-security.php with random 8-char code to site root
- User must read code from filesystem and enter it in browser
- Proves filesystem access before any restore actions are allowed
- Security file auto-deleted after successful verification
- All AJAX actions blocked until verification completes
2026-06-18 10:42:10 -05:00
gitea-actions[bot] b3e7c8ec72 chore(version): pre-release bump to 01.22.07-dev [skip ci] 2026-06-18 15:27:08 +00:00
Jonathan Miller 9656a2a92b fix: PR #46 review — error handling, failure notifications, cleanup
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (push) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Generic: Project CI / Lint & Validate (push) Successful in 11s
Universal: Auto Version Bump / Version Bump (push) Successful in 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Generic: Project CI / Lint & Validate (pull_request) Successful in 35s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 38s
Generic: Project CI / Tests (push) Has been cancelled
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Critical:
- Wrap cleanupOldBackups() in try-catch to prevent admin panel crash
- Add missing fields (total_size, files_count, etc.) to failure record
  so failure notifications actually send

High:
- Log unlink failures in deleteBackupRecord() instead of silent return
- Wrap DB delete in try-catch so one failed record doesn't abort loop
- Check for ext-curl before calling curl_init() in sendNtfy()

Medium:
- Change runPreActionBackup catch from \Exception to \Throwable
- Log warning for skipped files during archive encryption
- Truncate ntfy response body in error logs (200 chars max)
2026-06-18 10:26:48 -05:00
73 changed files with 3786 additions and 823 deletions
+1
View File
@@ -60,3 +60,4 @@ CODE_OF_CONDUCT.md export-ignore
Makefile export-ignore
composer.json export-ignore
phpstan.neon export-ignore
*.yml text eol=lf
-21
View File
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<mokoplatform xmlns="https://standards.mokoconsulting.tech/mokoplatform/1.0" schema-version="1.0">
<identity>
<name>MokoSuiteBackup</name>
<display-name>Package - MokoSuiteBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.22.06-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>joomla</platform>
<standards-version>05.00.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/mokoplatform</standards-source>
</governance>
<build>
<language>PHP</language>
<package-type>joomla-extension</package-type>
<entry-point>source/</entry-point>
</build>
</mokoplatform>
+9 -9
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# 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)
@@ -43,19 +43,19 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
- 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/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
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/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
"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
+153 -37
View File
@@ -4,15 +4,15 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml
#
# +========================================================================+
# +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+
# +=======================================================================+
# | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | |
@@ -21,7 +21,7 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream |
# | |
# +========================================================================+
# +=======================================================================+
name: "Universal: Build & Release"
@@ -51,7 +51,7 @@ permissions:
contents: write
jobs:
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────
# ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc:
name: Promote to RC
runs-on: release
@@ -66,25 +66,25 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; 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
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Rename branch to rc
@@ -109,13 +109,47 @@ jobs:
--path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update RC release notes from CHANGELOG.md
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Release candidate"
# Find the RC release and update its body
RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/release-candidate" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
body = open('/dev/stdin').read()
payload = json.dumps({'body': body}).encode()
req = urllib.request.Request(
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
" <<< "$NOTES"
echo "RC release notes updated from CHANGELOG.md"
fi
- name: Summary
if: always()
run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
# ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release:
name: Build & Release Pipeline
runs-on: release
@@ -149,28 +183,34 @@ jobs:
fi
echo "No conflict markers found"
- name: Setup moko-platform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: |
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; 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
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level"
id: bump
run: |
@@ -194,22 +234,80 @@ jobs:
--path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: Update release notes from CHANGELOG.md
- name: "Read published version"
id: version
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
# Extract [Unreleased] section from changelog
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
[ -z "$NOTES" ] && NOTES="Stable release"
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
NOTES="Stable release"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
# Update release body via API
RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API_BASE}/releases/tags/stable" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Get the stable release info (version and ID)
RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
"${API_BASE}/releases/tags/stable" 2>/dev/null || echo '{}')
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract version from release name (e.g. "06.17.00" or "v06.17.00")
VERSION=$(python3 -c "
import json, sys, re
r = json.load(sys.stdin)
name = r.get('name', '')
m = re.search(r'(\d+\.\d+\.\d+)', name)
print(m.group(1) if m else '')
" <<< "$RELEASE_JSON" 2>/dev/null || true)
# Extract [Unreleased] section from changelog
NOTES=""
if [ -f "CHANGELOG.md" ]; then
NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md)
fi
[ -z "$NOTES" ] && NOTES="Stable release"
# Update release body via API
if [ -n "$RELEASE_ID" ]; then
python3 -c "
import json, urllib.request
@@ -219,7 +317,7 @@ jobs:
'${API_BASE}/releases/${RELEASE_ID}',
data=payload, method='PATCH',
headers={
'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}',
'Authorization': 'token ${TOKEN}',
'Content-Type': 'application/json'
})
urllib.request.urlopen(req)
@@ -227,6 +325,24 @@ jobs:
echo "Release notes updated from CHANGELOG.md"
fi
# Promote [Unreleased] → [version] in CHANGELOG.md and reset
if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
DATE=$(date +%Y-%m-%d)
python3 -c "
import sys
version, date = sys.argv[1], sys.argv[2]
content = open('CHANGELOG.md').read()
old = '## [Unreleased]'
new = f'## [Unreleased]\n\n## [{version}] --- {date}'
content = content.replace(old, new, 1)
open('CHANGELOG.md', 'w').write(content)
" "$VERSION" "$DATE"
git add CHANGELOG.md
git commit -m "chore: promote changelog [Unreleased] → [${VERSION}]" || true
git push origin main || true
echo "Changelog promoted: [Unreleased] → [${VERSION}]"
fi
# -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub"
if: >-
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
-13
View File
@@ -13,19 +13,6 @@
name: "Generic: Project CI"
on:
push:
branches:
- main
- dev
- dev/**
- rc/**
- version/**
pull_request:
branches:
- main
- dev
- dev/**
- rc/**
workflow_dispatch:
permissions:
+410 -7
View File
@@ -45,17 +45,17 @@ jobs:
fi
php -v && composer --version
- name: Setup moko-platform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.GA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.GA_TOKEN || github.token }}
MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }}
run: |
if [ -d "/tmp/moko-platform" ] || [ -d "/opt/moko-platform" ]; then
echo "moko-platform already available on runner — skipping clone"
if [ -d "/opt/mokocli" ] || [ -d "/tmp/mokocli" ]; then
echo "mokocli already available on runner — skipping clone"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform 2>/dev/null || echo "moko-platform clone skipped — continuing without it"
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git" \
/tmp/mokocli 2>/dev/null || echo "mokocli clone skipped — continuing without it"
fi
- name: Install dependencies
@@ -245,10 +245,413 @@ jobs:
echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY
fi
- name: Check config.xml and access.xml for components
run: |
echo "### Component Config & ACL Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find all component manifests (XML with type="component")
COMP_MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<extension[^>]*type="component"' {} ; 2>/dev/null || true)
if [ -z "$COMP_MANIFESTS" ]; then
echo "No component extensions found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $COMP_MANIFESTS; do
COMP_DIR=$(dirname "$MANIFEST")
COMP_NAME=$(basename "$COMP_DIR")
echo "Component: `${COMP_NAME}` (manifest: `${MANIFEST}`)" >> $GITHUB_STEP_SUMMARY
# Check access.xml exists
ACCESS_FILE=$(find "$COMP_DIR" -name "access.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$ACCESS_FILE" ]; then
echo "- Missing `access.xml` — ACL permissions will not work." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$ACCESS_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `access.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
for ACTION in core.admin core.manage; do
if ! grep -q "name=\"${ACTION}\"" "$ACCESS_FILE" 2>/dev/null; then
echo "- `access.xml` missing required action: `${ACTION}`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
echo "- `access.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
# Check config.xml exists
CONFIG_FILE=$(find "$COMP_DIR" -name "config.xml" -not -path "./.git/*" 2>/dev/null | head -1)
if [ -z "$CONFIG_FILE" ]; then
echo "- Missing `config.xml` — component Options page will be empty." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$CONFIG_FILE') ?: exit(1);" 2>/dev/null; then
echo "- `config.xml` is not well-formed XML." >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- `config.xml`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} config/ACL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Component config & ACL check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SQL schema validation
run: |
echo "### SQL Schema Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find SQL files in source/htdocs
SQL_FILES=$(find . -name "*.sql" -path "*/sql/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$SQL_FILES" ]; then
echo "No SQL files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$SQL_FILES" | wc -l) SQL file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $SQL_FILES; do
# Basic syntax check: balanced parentheses, no empty files
SIZE=$(wc -c < "$FILE" | tr -d ' ')
if [ "$SIZE" -eq 0 ]; then
echo "- Empty SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
# Check for common SQL errors
if grep -qP '^\s*$' "$FILE" && [ "$SIZE" -lt 5 ]; then
echo "- Whitespace-only SQL file: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
continue
fi
echo "- \`${FILE}\`: ${SIZE} bytes" >> $GITHUB_STEP_SUMMARY
done
# Check update SQL files follow version numbering pattern
UPDATE_DIR=$(find . -path "*/sql/updates/mysql" -type d -not -path "./.git/*" 2>/dev/null | head -1)
if [ -n "$UPDATE_DIR" ]; then
BAD_NAMES=0
for UFILE in "$UPDATE_DIR"/*.sql; do
[ ! -f "$UFILE" ] && continue
BASENAME=$(basename "$UFILE" .sql)
if ! echo "$BASENAME" | grep -qP '^\d+\.\d+\.\d+'; then
echo "- Update file \`${UFILE}\` does not follow version naming (expected X.Y.Z.sql)" >> $GITHUB_STEP_SUMMARY
BAD_NAMES=$((BAD_NAMES + 1))
fi
done
if [ "$BAD_NAMES" -gt 0 ]; then
ERRORS=$((ERRORS + BAD_NAMES))
fi
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} SQL issue(s) found.**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**SQL schema validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Manifest file references check
run: |
echo "### Manifest File References" >> $GITHUB_STEP_SUMMARY
ERRORS=0
MANIFEST=""
for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do
if grep -q "<extension" "$XML_FILE" 2>/dev/null; then
MANIFEST="$XML_FILE"
break
fi
done
if [ -z "$MANIFEST" ]; then
echo "No manifest found — skipping." >> $GITHUB_STEP_SUMMARY
else
MANIFEST_DIR=$(dirname "$MANIFEST")
# Check <filename> references
FILENAMES=$(grep -oP '<filename[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILENAMES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ] && [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <folder> references
FOLDERS=$(grep -oP '<folder[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FOLDERS; do
if [ ! -d "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing folder: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
# Check <file> references in package manifests (ZIP files won't exist in source)
EXT_TYPE=$(grep -oP '<extension[^>]*\btype="\K[^"]+' "$MANIFEST" | head -1)
if [ "$EXT_TYPE" != "package" ]; then
FILES=$(grep -oP '<file[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true)
for F in $FILES; do
if [ ! -f "${MANIFEST_DIR}/${F}" ]; then
echo "- Missing file: \`${F}\` (referenced in manifest)" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} missing file reference(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Manifest file references check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Form XML validation
run: |
echo "### Form XML Validation" >> $GITHUB_STEP_SUMMARY
ERRORS=0
FORM_FILES=$(find . -name "*.xml" -path "*/forms/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$FORM_FILES" ]; then
echo "No form XML files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$FORM_FILES" | wc -l) form file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $FORM_FILES; do
if command -v php &> /dev/null; then
if ! php -r "@simplexml_load_file('$FILE') ?: exit(1);" 2>/dev/null; then
echo "- \`${FILE}\`: malformed XML" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
# Check for valid Joomla form structure
if ! grep -qE '<form|<field|<fieldset' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: no \`<form>\`, \`<field>\`, or \`<fieldset>\` elements found" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid" >> $GITHUB_STEP_SUMMARY
fi
fi
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} form XML issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Form XML validation passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: Deprecated Joomla API check
continue-on-error: true
run: |
echo "### Deprecated Joomla API Check" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
# Joomla 3/4 deprecated patterns that break in Joomla 6
PATTERNS=(
'JFactory::'
'JText::'
'JHtml::'
'JRoute::'
'JUri::'
'JLog::'
'JTable::'
'JInput'
'CMSFactory::\$application'
'JApplicationCms'
)
for PATTERN in "${PATTERNS[@]}"; do
HITS=$(grep -rnl "$PATTERN" "$SRC_DIR" --include="*.php" 2>/dev/null || true)
if [ -n "$HITS" ]; then
COUNT=$(echo "$HITS" | wc -l)
echo "- \`${PATTERN}\` found in ${COUNT} file(s)" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + COUNT))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} deprecated API usage(s) found.** These will break in Joomla 6." >> $GITHUB_STEP_SUMMARY
else
echo "**No deprecated APIs found.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Template output escaping check
continue-on-error: true
run: |
echo "### Template Output Escaping" >> $GITHUB_STEP_SUMMARY
WARNINGS=0
TMPL_FILES=$(find . -name "*.php" -path "*/tmpl/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$TMPL_FILES" ]; then
echo "No template files found — skipping." >> $GITHUB_STEP_SUMMARY
else
echo "Found $(echo "$TMPL_FILES" | wc -l) template file(s)" >> $GITHUB_STEP_SUMMARY
for FILE in $TMPL_FILES; do
# Check for unescaped output: <?= $var ?> or echo $var without escape()
UNESCAPED=$(grep -nP '<\?=\s*\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$UNESCAPED" ]; then
HITS=$(echo "$UNESCAPED" | wc -l)
echo "- \`${FILE}\`: ${HITS} unescaped \`<?= \$var ?>\` output(s) — use \`<?= \$this->escape(\$var) ?>\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
# Check for echo without escaping in template context
RAW_ECHO=$(grep -nP '^\s*echo\s+\$(?!this->escape)' "$FILE" 2>/dev/null || true)
if [ -n "$RAW_ECHO" ]; then
HITS=$(echo "$RAW_ECHO" | wc -l)
echo "- \`${FILE}\`: ${HITS} raw \`echo \$var\` — consider \`echo \$this->escape(\$var)\`" >> $GITHUB_STEP_SUMMARY
WARNINGS=$((WARNINGS + HITS))
fi
done
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$WARNINGS" -gt 0 ]; then
echo "**${WARNINGS} potential XSS risk(s) in templates.** Review unescaped output." >> $GITHUB_STEP_SUMMARY
else
echo "**All template output appears properly escaped.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Namespace consistency check
run: |
echo "### Namespace Consistency" >> $GITHUB_STEP_SUMMARY
ERRORS=0
# Find component/plugin manifests with <namespace> tags
MANIFESTS=$(find . -maxdepth 4 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*" -exec grep -l '<namespace' {} \; 2>/dev/null || true)
if [ -z "$MANIFESTS" ]; then
echo "No manifests with \`<namespace>\` found — skipping." >> $GITHUB_STEP_SUMMARY
else
for MANIFEST in $MANIFESTS; do
NS_PATH=$(grep -oP '<namespace[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1)
[ -z "$NS_PATH" ] && continue
MANIFEST_DIR=$(dirname "$MANIFEST")
echo "Manifest: \`${MANIFEST}\` → namespace \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
# Check PHP files have matching namespace
while IFS= read -r -d '' PHP_FILE; do
FILE_NS=$(grep -oP '^\s*namespace\s+\K[^;]+' "$PHP_FILE" 2>/dev/null | head -1)
[ -z "$FILE_NS" ] && continue
# Namespace should start with the manifest namespace path
if ! echo "$FILE_NS" | grep -qF "${NS_PATH}"; then
echo "- \`${PHP_FILE}\`: namespace \`${FILE_NS}\` doesn't match manifest \`${NS_PATH}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done < <(find "$MANIFEST_DIR" -name "*.php" -path "*/src/*" -not -path "./vendor/*" -print0 2>/dev/null)
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} namespace mismatch(es).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Namespace consistency check passed.**" >> $GITHUB_STEP_SUMMARY
fi
- name: SPDX license header check
continue-on-error: true
run: |
echo "### SPDX License Headers" >> $GITHUB_STEP_SUMMARY
MISSING=0
SRC_DIR=""
for DIR in source/ src/ htdocs/; do
[ -d "$DIR" ] && SRC_DIR="$DIR" && break
done
if [ -z "$SRC_DIR" ]; then
echo "No source directory found — skipping." >> $GITHUB_STEP_SUMMARY
else
TOTAL=0
while IFS= read -r -d '' FILE; do
TOTAL=$((TOTAL + 1))
if ! head -10 "$FILE" | grep -qi "SPDX"; then
echo "- Missing SPDX header: \`${FILE}\`" >> $GITHUB_STEP_SUMMARY
MISSING=$((MISSING + 1))
fi
done < <(find "$SRC_DIR" -name "*.php" -not -path "./vendor/*" -print0)
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$MISSING" -gt 0 ]; then
echo "**${MISSING}/${TOTAL} PHP file(s) missing SPDX license header.**" >> $GITHUB_STEP_SUMMARY
else
echo "**All ${TOTAL} PHP files have SPDX headers.**" >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Service provider check
run: |
echo "### Service Provider Check" >> $GITHUB_STEP_SUMMARY
ERRORS=0
PROVIDERS=$(find . -name "provider.php" -path "*/services/*" -not -path "./.git/*" -not -path "./vendor/*" 2>/dev/null)
if [ -z "$PROVIDERS" ]; then
echo "No service providers found — skipping." >> $GITHUB_STEP_SUMMARY
else
for FILE in $PROVIDERS; do
# Must return a ServiceProviderInterface
if ! grep -qP 'ServiceProviderInterface|ComponentInterface|MVCFactoryInterface|DispatcherInterface' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: does not reference ServiceProviderInterface or component interfaces" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
else
echo "- \`${FILE}\`: valid service provider" >> $GITHUB_STEP_SUMMARY
fi
# Must have return statement
if ! grep -qP '^\s*return\s+new\s+' "$FILE" 2>/dev/null; then
echo "- \`${FILE}\`: missing \`return new ...\` statement" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS + 1))
fi
done
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${ERRORS}" -gt 0 ]; then
echo "**${ERRORS} service provider issue(s).**" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "**Service provider check passed.**" >> $GITHUB_STEP_SUMMARY
fi
release-readiness:
name: Release Readiness Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.base_ref == 'main'
continue-on-error: true
steps:
- name: Checkout repository
+76
View File
@@ -0,0 +1,76 @@
# 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
-4
View File
@@ -25,10 +25,6 @@
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
+3 -3
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokoplatform.Automation
# VERSION: 01.22.06
# INGROUP: mokocli.Automation
# VERSION: 01.28.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
TOKEN="${{ secrets.GA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
+26
View File
@@ -96,6 +96,32 @@ jobs:
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Secret Scanning ──────────────────────────────────────────────────
gitleaks:
name: Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
- name: Scan PR commits for secrets
run: |
if gitleaks detect --source . --verbose \
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Potential secrets detected in PR commits"
exit 1
fi
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/joomla/pr-metadata-check.yml.template
# VERSION: 01.00.00
# BRIEF: Validate MokoGitea metadata matches Joomla extension manifest on PRs
name: "Joomla: Metadata Validation"
on:
pull_request:
types: [opened, synchronize, reopened, converted_to_draft, ready_for_review]
permissions:
contents: read
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
jobs:
validate-metadata:
name: "Validate Joomla Metadata"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
if [ -f /opt/mokocli/cli/joomla_metadata_validate.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; 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
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Validate metadata against Joomla manifest
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
php ${MOKO_CLI}/joomla_metadata_validate.php \
--path . \
--token "${GITEA_TOKEN}" \
--org "${GITEA_ORG}" \
--repo "${GITEA_REPO}" \
--api-base "${GITEA_URL}/api/v1" \
--ci
if [ $? -ne 0 ]; then
echo "::error::Joomla metadata mismatch — update delivery will fail. Run 'php cli/joomla_metadata_validate.php' locally to see details."
exit 1
fi
+28 -12
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
@@ -60,25 +60,25 @@ jobs:
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- name: Setup moko-platform tools
- name: Setup mokocli tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: |
# Use pre-installed /opt/moko-platform if available (updated by cron every 6h)
if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then
echo Using pre-installed /opt/moko-platform
echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV
# Use pre-installed /opt/mokocli if available (updated by cron every 6h)
if [ -f /opt/mokocli/cli/version_bump.php ] && [ -f /opt/mokocli/cli/manifest_element.php ] && [ -f /opt/mokocli/vendor/autoload.php ]; then
echo Using pre-installed /opt/mokocli
echo MOKO_CLI=/opt/mokocli/cli >> $GITHUB_ENV
else
echo Falling back to fresh clone
if ! command -v composer > /dev/null 2>&1; 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
rm -rf /tmp/moko-platform-api
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV
rm -rf /tmp/mokocli
CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/mokocli.git
git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/mokocli
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi
- name: Detect platform
@@ -88,8 +88,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version
id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
@@ -166,6 +178,7 @@ jobs:
- name: Create release
id: release
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -176,6 +189,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
@@ -212,6 +226,7 @@ jobs:
- name: Build package and upload
id: package
if: steps.eligibility.outputs.proceed == 'true'
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
@@ -225,6 +240,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
+4 -3
View File
@@ -7,8 +7,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# INGROUP: mokocli.Validation
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/mokocli
# PATH: /templates/workflows/joomla/repo_health.yml.template
# VERSION: 09.23.00
# BRIEF: Enforces repository guardrails by validating scripts governance, tooling availability, and core repository health artifacts.
@@ -33,7 +33,8 @@ on:
- scripts
- repo
pull_request:
push:
branches:
- main
permissions:
contents: read
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+6 -30
View File
@@ -1,38 +1,14 @@
# Changelog
## [Unreleased]
## [01.21.00] --- 2026-06-16
## [01.27.03] --- 2026-06-21
### Fixed
- Admin submenu items (Dashboard, Backups, Profiles) not created on install — `<submenu>` block in manifest was empty
- Submenu items not created on update — added `ensureSubmenuItems()` using Joomla's `MenuTable` API with proper nested set positioning
- Submenu icons not rendering in Joomla 6 — set `menu_icon` param for level 2+ items (Atum only renders `img` column icons for level 1)
- CSS selector `#menu``.main-nav` for icon injection (Joomla 6 uses dynamic `id="menu{moduleId}"`)
- Use `margin-inline-end` instead of `margin-right` for RTL layout support
## [01.27.03] --- 2026-06-21
## [01.08.00] --- 2026-06-07
## [01.27.00] --- 2026-06-21
## [01.07.00] --- 2026-06-07
## [01.27.00] --- 2026-06-21
## [01.06.00] --- 2026-06-07
## [01.27.00] --- 2026-06-21
## [01.05.00] --- 2026-06-07
### Added
- Dashboard submenu entry as default landing page with `class:home` icon
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokosuitebackup/backups` at runtime
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
- `checkDir` AJAX endpoint for real-time directory permission checking
- Web-accessible warning badge on backup download buttons when archive is inside web root
- Inline security warning in FolderPicker when default directory is selected
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
- Font Awesome 6 submenu icons via CSS injection in `MokoSuiteBackupComponent::boot()`
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
### Changed
- Profile `backup_dir` default changed from literal path to `[DEFAULT_DIR]` placeholder
- Backup engine fallback directory changed from hardcoded path to `[DEFAULT_DIR]`
- `isUsingDefaultBackupDir()` now matches `[DEFAULT_DIR]` placeholder in addition to literal path and empty values
- Dashboard submenu language key added to `.sys.ini` files (en-GB, en-US)
## [01.27.00] --- 2026-06-21
-165
View File
@@ -1,165 +0,0 @@
# Makefile for Joomla Extensions
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoSuiteBackup — Full-site backup and restore for Joomla
#
# Builds and releases are handled by CI workflows (pre-release.yml,
# auto-release.yml). This Makefile provides local validation helpers
# and workflow dispatch shortcuts.
# ==============================================================================
# CONFIGURATION
# ==============================================================================
EXTENSION_NAME := mokosuitebackup
EXTENSION_TYPE := package
SRC_DIR := source
# Gitea
GITEA_URL := https://git.mokoconsulting.tech
GITEA_ORG := MokoConsulting
GITEA_REPO := MokoSuiteBackup
# Tools
PHP := php
COMPOSER := composer
PHPCS := vendor/bin/phpcs
# Coding Standards
PHPCS_STANDARD := Joomla
# Colors for output
COLOR_RESET := \033[0m
COLOR_GREEN := \033[32m
COLOR_YELLOW := \033[33m
COLOR_BLUE := \033[34m
COLOR_RED := \033[31m
# ==============================================================================
# TARGETS
# ==============================================================================
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ MokoSuiteBackup Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo ""
# -- Validation ----------------------------------------------------------------
.PHONY: lint
lint: ## Run PHP syntax check on all source files
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@ERROR=0; \
find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -v "No syntax errors" || true; \
if find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -q "Parse error"; then \
echo "$(COLOR_RED)✗ Syntax errors found$(COLOR_RESET)"; exit 1; \
fi
@echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php $(SRC_DIR); \
else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \
fi
.PHONY: validate
validate: lint ## Run all local validation checks
@echo "$(COLOR_GREEN)✓ Validation passed$(COLOR_RESET)"
.PHONY: validate-xml
validate-xml: ## Validate all XML manifests are well-formed
@echo "$(COLOR_BLUE)Validating XML manifests...$(COLOR_RESET)"
@ERROR=0; \
for f in $$(find $(SRC_DIR) -name "*.xml"); do \
$(PHP) -r "new SimpleXMLElement(file_get_contents('$$f'));" 2>/dev/null \
|| { echo "$(COLOR_RED)✗ Invalid XML: $$f$(COLOR_RESET)"; ERROR=1; }; \
done; \
[ $$ERROR -eq 0 ] && echo "$(COLOR_GREEN)✓ All XML manifests valid$(COLOR_RESET)" || exit 1
# -- Dependencies --------------------------------------------------------------
.PHONY: install-deps
install-deps: ## Install PHP dependencies via Composer
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: security-check
security-check: ## Run security audit on dependencies
@echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \
fi
# -- Minify --------------------------------------------------------------------
MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify
minify: ## Minify CSS/JS assets
@echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)"
@if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \
else \
echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \
fi
# -- Release (CI workflow dispatch) --------------------------------------------
.PHONY: release
release: validate validate-xml ## Trigger pre-release build via CI workflow
@echo "$(COLOR_BLUE)Triggering pre-release workflow...$(COLOR_RESET)"
@if ! command -v curl >/dev/null 2>&1; then \
echo "$(COLOR_RED)✗ curl required$(COLOR_RESET)"; exit 1; \
fi
@if [ -z "$$MOKOGITEA_TOKEN" ]; then \
echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \
fi
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
curl -sf -X POST \
-H "Authorization: token $$MOKOGITEA_TOKEN" \
-H "Content-Type: application/json" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \
-d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"development\"}}" \
&& echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (development channel)$(COLOR_RESET)" \
|| { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; }
.PHONY: release-rc
release-rc: validate validate-xml ## Trigger release-candidate build via CI workflow
@echo "$(COLOR_BLUE)Triggering RC pre-release workflow...$(COLOR_RESET)"
@if [ -z "$$MOKOGITEA_TOKEN" ]; then \
echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \
fi
@BRANCH=$$(git rev-parse --abbrev-ref HEAD); \
curl -sf -X POST \
-H "Authorization: token $$MOKOGITEA_TOKEN" \
-H "Content-Type: application/json" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \
-d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"release-candidate\"}}" \
&& echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (release-candidate channel)$(COLOR_RESET)" \
|| { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; }
# -- Info ----------------------------------------------------------------------
.PHONY: version
version: ## Display version from package manifest
@VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokosuitebackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
# Default target
.DEFAULT_GOAL := help
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.22.06 -->
<!-- VERSION: 01.28.01 -->
Full-site backup and restore for Joomla — database, files, and configuration.
-237
View File
@@ -1,237 +0,0 @@
#!/usr/bin/env bash
# ============================================================================
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Automation.CI
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /automation/ci-issue-reporter.sh
# VERSION: 09.23.00
# BRIEF: Creates or updates a Gitea issue when a CI gate fails.
# Deduplicates by searching open issues with the "ci-auto" label
# whose title matches the gate. If a matching issue exists, a comment
# is appended instead of opening a duplicate.
# ============================================================================
set -euo pipefail
# ── Defaults ────────────────────────────────────────────────────────────────
GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
REPO="${GITHUB_REPOSITORY:-}"
RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}"
LABEL_NAME="ci-auto"
LABEL_COLOR="#e11d48"
GATE=""
DETAILS=""
SEVERITY="error"
WORKFLOW=""
# ── Parse arguments ─────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: ci-issue-reporter.sh --gate NAME --details TEXT [OPTIONS]
Required:
--gate CI gate name (e.g. "Code Quality", "Self-Health")
--details Human-readable failure description
Optional:
--severity "error" (default) or "warning"
--workflow Workflow name for the issue title
--repo owner/repo (default: \$GITHUB_REPOSITORY)
--run-url URL to the CI run (auto-detected from env)
--token Gitea API token (default: \$GITEA_TOKEN)
--url Gitea base URL (default: \$GITEA_URL)
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gate) GATE="$2"; shift 2 ;;
--details) DETAILS="$2"; shift 2 ;;
--severity) SEVERITY="$2"; shift 2 ;;
--workflow) WORKFLOW="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--run-url) RUN_URL="$2"; shift 2 ;;
--token) GITEA_TOKEN="$2"; shift 2 ;;
--url) GITEA_URL="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
[[ -z "$GATE" ]] && { echo "ERROR: --gate is required"; usage; }
[[ -z "$DETAILS" ]] && { echo "ERROR: --details is required"; usage; }
[[ -z "$GITEA_TOKEN" ]] && { echo "ERROR: GITEA_TOKEN not set"; exit 1; }
[[ -z "$REPO" ]] && { echo "ERROR: GITHUB_REPOSITORY not set"; exit 1; }
API="${GITEA_URL}/api/v1/repos/${REPO}"
# ── Build title ─────────────────────────────────────────────────────────────
if [[ -n "$WORKFLOW" ]]; then
TITLE="[CI] ${WORKFLOW}: ${GATE} failed"
else
TITLE="[CI] ${GATE} failed"
fi
# ── Ensure label exists ─────────────────────────────────────────────────────
ensure_label() {
local exists
exists=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null || echo "000")
if [[ "$exists" == "200" ]]; then
# Check if label already exists
local found
found=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -o "\"name\":\"${LABEL_NAME}\"" || true)
if [[ -z "$found" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/labels" \
-d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \
> /dev/null 2>&1 || true
fi
fi
}
# ── Search for existing open issue ──────────────────────────────────────────
find_existing_issue() {
# URL-encode the gate name for the query
local query
query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g')
local response
response=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \
2>/dev/null || echo "[]")
# Extract the first matching issue number
echo "$response" \
| grep -oP '"number":\s*\K[0-9]+' \
| head -1
}
# ── Build issue body ────────────────────────────────────────────────────────
build_body() {
local severity_badge
if [[ "$SEVERITY" == "error" ]]; then
severity_badge="**Severity:** Error"
else
severity_badge="**Severity:** Warning"
fi
cat <<BODY
## CI Gate Failure: ${GATE}
${severity_badge}
**Workflow:** ${WORKFLOW:-unknown}
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
### Details
${DETAILS}
### Resolution
Fix the issue described above and push a new commit. This issue will be closed automatically when the gate passes, or can be closed manually.
---
*Auto-created by [ci-issue-reporter](${GITEA_URL}/${REPO}/src/branch/main/automation/ci-issue-reporter.sh)*
BODY
}
# ── Build comment body (for existing issues) ────────────────────────────────
build_comment() {
cat <<COMMENT
### CI failure recurrence
**Branch:** ${GITHUB_REF_NAME:-unknown}
**Commit:** \`${GITHUB_SHA:0:8}\`
**Run:** [View CI run](${RUN_URL})
${DETAILS}
COMMENT
}
# ── Main ────────────────────────────────────────────────────────────────────
ensure_label
EXISTING=$(find_existing_issue)
if [[ -n "$EXISTING" ]]; then
# Append comment to existing issue
COMMENT_BODY=$(build_comment)
COMMENT_JSON=$(printf '%s' "$COMMENT_BODY" | python3 -c "
import sys, json
print(json.dumps({'body': sys.stdin.read()}))" 2>/dev/null)
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${EXISTING}/comments" \
-d "${COMMENT_JSON}" 2>/dev/null || echo "000")
if [[ "$HTTP" == "201" ]]; then
echo "Commented on existing issue #${EXISTING}"
else
echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})"
fi
else
# Create new issue
ISSUE_BODY=$(build_body)
ISSUE_JSON=$(python3 -c "
import sys, json
body = sys.stdin.read()
print(json.dumps({
'title': sys.argv[1],
'body': body,
'labels': []
}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null)
# Create the issue
RESPONSE=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues" \
-d "${ISSUE_JSON}" 2>/dev/null || echo "{}")
ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1)
if [[ -n "$ISSUE_NUM" ]]; then
# Apply label (separate call — more reliable across Gitea versions)
LABEL_ID=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/labels" 2>/dev/null \
| grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \
| head -1 || true)
if [[ -n "$LABEL_ID" ]]; then
curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/labels" \
-d "{\"labels\":[${LABEL_ID}]}" \
> /dev/null 2>&1 || true
fi
echo "Created issue #${ISSUE_NUM}: ${TITLE}"
else
echo "WARNING: Failed to create issue"
echo "Response: ${RESPONSE}"
fi
fi
-11
View File
@@ -1,11 +0,0 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_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
*/
defined('_JEXEC') or die;
-31
View File
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package 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
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<creationDate>2026-06-02</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>PLG_WEBSERVICES_MOKOJOOMBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\WebServices\MokoSuiteBackup</namespace>
<files>
<filename plugin="mokosuitebackup">mokosuitebackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.sys.ini</language>
</languages>
</extension>
@@ -11,5 +11,6 @@
<action name="mokosuitebackup.backup.run" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN" />
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
</section>
</access>
@@ -121,11 +121,27 @@ class BackupsController extends ApiController
$data = [];
// Strip sensitive credentials before serialization
$sensitiveFields = [
'ftp_password', 'ftp_username',
's3_access_key', 's3_secret_key',
'gdrive_client_secret', 'gdrive_refresh_token',
'encryption_password', 'ntfy_token',
];
foreach ($items as $item) {
$safe = clone $item;
foreach ($sensitiveFields as $field) {
if (isset($safe->$field) && $safe->$field !== '') {
$safe->$field = '***';
}
}
$data[] = [
'type' => 'profiles',
'id' => $item->id,
'attributes' => $item,
'id' => $safe->id,
'attributes' => $safe,
];
}
@@ -13,7 +13,7 @@
type="FolderPicker"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
default="administrator/components/com_mokosuitebackup/backups"
default="[DEFAULT_DIR]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
@@ -21,10 +21,10 @@
type="sql"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
query="SELECT id AS value, title AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
query="SELECT id AS value, CONCAT(title, ' (#', id, ')') AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
<option value="1">Default Backup Profile (#1)</option>
</field>
<field
name="show_update_notice"
@@ -42,12 +42,13 @@
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
<field
name="webcron_secret"
type="text"
type="WebcronSecret"
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET"
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET_DESC"
default=""
filter="string"
maxlength="64"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="webcron_enabled"
@@ -62,12 +63,12 @@
</field>
<field
name="webcron_ip_whitelist"
type="text"
type="IpWhitelist"
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP"
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP_DESC"
default=""
filter="string"
hint="Leave blank to allow any IP"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
@@ -19,6 +19,18 @@
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
</field>
<field
name="backup_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE"
onchange="this.form.submit();"
>
<option value="">COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL</option>
<option value="full">COM_MOKOJOOMBACKUP_TYPE_FULL</option>
<option value="database">COM_MOKOJOOMBACKUP_TYPE_DATABASE</option>
<option value="files">COM_MOKOJOOMBACKUP_TYPE_FILES</option>
<option value="differential">COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL</option>
</field>
</fields>
<fields name="list">
@@ -167,6 +167,7 @@ COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending"
COM_MOKOJOOMBACKUP_FILTER_SEARCH="Search"
COM_MOKOJOOMBACKUP_FILTER_STATUS="Status"
COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL="- Select Status -"
COM_MOKOJOOMBACKUP_FILTER_TYPE_ALL="- Select Type -"
; Tabs and fieldsets
COM_MOKOJOOMBACKUP_TAB_GENERAL="General"
@@ -299,6 +300,64 @@ COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store bac
COM_MOKOJOOMBACKUP_WEB_ACCESSIBLE_WARNING="This backup is stored inside the web root and may be directly downloadable if .htaccess is not supported."
; Restore modal
COM_MOKOJOOMBACKUP_RESTORE_FILES="Restore files"
COM_MOKOJOOMBACKUP_RESTORE_DATABASE="Restore database"
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG="Preserve current configuration.php"
COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC="Keep your current database credentials and site paths. Recommended unless you know the backup has the correct credentials."
COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER="Leave blank if archive is not encrypted"
; Snapshots
COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS="Content Snapshots"
COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE="Content Snapshots"
COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION="Table of content snapshots"
COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE="No snapshots found. Click 'Create Snapshot' to save a snapshot of your content."
COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE="Create Snapshot"
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE="Restore Snapshot"
COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES="Select content to snapshot"
COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER="e.g. Before redesign, Pre-migration"
COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES="Content Types"
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES="Articles"
COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC="All articles, frontpage settings, workflow state, and tags"
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES="Categories"
COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC="Content categories (com_content)"
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES="Modules"
COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC="All modules and their menu assignments"
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES="Please select at least one content type to snapshot."
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD="No snapshot selected."
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE="Restore Mode"
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE="Replace (clean)"
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC="Remove all existing content of the selected types and replace with snapshot data. This gives you an exact copy of the snapshot state."
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE="Merge (upsert)"
COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC="Update existing items by ID and insert missing ones. Content added after the snapshot is preserved."
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES="Types to restore"
COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING="Replace mode will delete all current content of the selected types before restoring from the snapshot. This cannot be undone."
COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
; Snapshot ACL
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
; Webcron secret field
COM_MOKOJOOMBACKUP_WEBCRON_GENERATE="Generate"
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE="No secret set — webcron is disabled."
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT="Too short — minimum %d characters required."
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK="Weak — avoid common words like 'password', 'admin', 'secret'."
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK="Acceptable — consider making it longer for better security."
COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG="Strong secret word."
; IP whitelist field
COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP="Your current IP"
COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP="Add my IP"
COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED="Included"
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS="IP Address"
COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE="Remove"
COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger webcron (if secret is correct)."
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
; 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."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -45,6 +45,9 @@
<menu link="option=com_mokosuitebackup&amp;view=backups"
img="class:database"
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokosuitebackup&amp;view=snapshots"
img="class:camera"
alt="Snapshots">COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS</menu>
<menu link="option=com_mokosuitebackup&amp;view=profiles"
img="class:cog"
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
@@ -78,6 +78,23 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
KEY `idx_backupstart` (`backupstart`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`description` VARCHAR(255) NOT NULL DEFAULT '',
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
`id`, `title`, `description`, `backup_type`,
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`description` VARCHAR(255) NOT NULL DEFAULT '',
`content_types` VARCHAR(255) NOT NULL DEFAULT '[]' COMMENT 'JSON array: ["articles","categories","modules"]',
`status` VARCHAR(20) NOT NULL DEFAULT 'complete' COMMENT 'complete, fail',
`articles_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`categories_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`modules_count` INT(11) UNSIGNED NOT NULL DEFAULT 0,
`data_file` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Absolute path to JSON snapshot file',
`data_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Size of JSON file in bytes',
`log` MEDIUMTEXT DEFAULT NULL COMMENT 'Snapshot operation log',
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
`created_by` INT(11) UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_created` (`created`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -49,6 +49,13 @@ class BackupsController extends AdminController
$engine = new BackupEngine();
$result = $engine->run($profileId, $description, 'backend');
// Surface preflight warnings as Joomla messages
if (!empty($result['warnings'])) {
foreach ($result['warnings'] as $warning) {
$this->app->enqueueMessage($warning, 'warning');
}
}
if ($result['success']) {
$this->setMessage($result['message']);
} else {
@@ -0,0 +1,179 @@
<?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\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
class SnapshotsController extends AdminController
{
protected $text_prefix = 'COM_MOKOJOOMBACKUP_SNAPSHOTS';
public function getModel($name = 'Snapshot', $prefix = 'Administrator', $config = ['ignore_request' => true])
{
return parent::getModel($name, $prefix, $config);
}
/**
* Create a new content snapshot.
*/
public function create(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$contentTypes = $this->input->get('content_types', [], 'array');
$description = $this->input->getString('description', '');
if (empty($contentTypes)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_TYPES'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$engine = new SnapshotEngine();
$result = $engine->create($contentTypes, $description);
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
}
/**
* Restore from a content snapshot.
*/
public function restore(): void
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$id = $this->input->getInt('id', 0);
$mode = $this->input->getCmd('restore_mode', 'replace');
$contentTypes = $this->input->get('restore_types', [], 'array');
// Enforce valid restore mode at controller boundary
if (!in_array($mode, ['replace', 'merge'], true)) {
$mode = 'replace';
}
if (!$id) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$engine = new SnapshotRestoreEngine();
$result = $engine->restore($id, $mode, $contentTypes);
if ($result['success']) {
$this->setMessage($result['message']);
} else {
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
}
/**
* Delete snapshot records and their data files.
*/
public function delete(): 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=snapshots', false));
return;
}
$cid = $this->input->get('cid', [], 'array');
if (empty($cid)) {
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
return;
}
$db = Factory::getDbo();
$deleted = 0;
$errors = [];
foreach ($cid as $id) {
$id = (int) $id;
try {
// Load record to get file path
$query = $db->getQuery(true)
->select($db->quoteName('data_file'))
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$dataFile = $db->loadResult();
// Delete data file
if ($dataFile && is_file($dataFile)) {
if (!unlink($dataFile)) {
error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $dataFile);
}
}
// Delete record
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$db->execute();
$deleted++;
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Failed to delete snapshot ' . $id . ': ' . $e->getMessage());
$errors[] = $id;
}
}
if (!empty($errors)) {
$this->setMessage(
Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted)
. ' ' . Text::sprintf('COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS', implode(', ', $errors)),
'warning'
);
} else {
$this->setMessage(Text::plural('COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED', $deleted));
}
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
}
}
@@ -360,16 +360,12 @@ class AkeebaImporter
return $result;
}
// Try JSON
// Parse as JSON only — unserialize is an object injection risk
$data = json_decode($raw, true);
if (!is_array($data)) {
// Try unserialize (older Akeeba versions)
$data = @unserialize($raw);
if (!is_array($data)) {
return $result;
}
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
return $result;
}
// Extract directory exclusions
@@ -32,16 +32,21 @@ class BackupEngine
*/
public function run(int $profileId, string $description, string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'success' => false,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
// Override PHP limits for long-running backup operations
$this->overridePhpLimits();
// Verify required extensions
$extCheck = $this->checkRequiredExtensions();
if ($extCheck !== true) {
return ['success' => false, 'message' => $extCheck];
}
$db = Factory::getDbo();
// Load profile
@@ -53,7 +58,12 @@ class BackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['success' => false, 'message' => 'Profile not found: ' . $profileId];
return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Log any preflight warnings
foreach ($preflightResult['warnings'] as $warning) {
$this->log('PREFLIGHT WARNING: ' . $warning);
}
// Read settings directly from profile columns
@@ -68,13 +78,14 @@ class BackupEngine
$this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (!BackupDirectory::ensureReady($this->backupDir)) {
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0];
return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']];
}
// Create backup record
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$archiveFormat = $profile->archive_format ?? 'zip';
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
@@ -120,12 +131,15 @@ class BackupEngine
$tablesCount = 0;
// Step 1: Database dump (unless files-only)
// Streams to a temp file to avoid loading the entire dump into RAM
$sqlTempFile = '';
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$dumper = new DatabaseDumper($excludeTables);
$sqlDump = $dumper->dump();
$archiver->addFromString('database.sql', $sqlDump);
$dbSize = strlen($sqlDump);
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount();
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
}
@@ -193,6 +207,11 @@ class BackupEngine
$archiver->close();
// Clean up temp SQL file (no longer needed after archive is closed)
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// Step 1.5: Apply AES-256 encryption (if configured)
$encryptionPassword = $profile->encryption_password ?? '';
@@ -300,20 +319,36 @@ class BackupEngine
'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
'record_id' => $recordId,
'warnings' => $preflightResult['warnings'],
];
} catch (\Throwable $e) {
$this->log('FATAL: ' . $e->getMessage());
// Clean up temp SQL file on failure
if (!empty($sqlTempFile) && is_file($sqlTempFile)) {
@unlink($sqlTempFile);
}
// If encryption was intended and failed, remove the plaintext archive
if (!empty($encryptionPassword) && !empty($archivePath) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Plaintext archive removed after encryption failure');
}
$update = (object) [
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'log' => implode("\n", $this->log),
'id' => $recordId,
'status' => 'fail',
'description' => $description ?: '',
'backup_type' => $profile->backup_type ?? 'full',
'origin' => $origin,
'archivename' => $archiveName,
'backupstart' => $now ?? date('Y-m-d H:i:s'),
'backupend' => date('Y-m-d H:i:s'),
'total_size' => 0,
'files_count' => 0,
'tables_count' => 0,
'remote_filename' => '',
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
@@ -324,7 +359,7 @@ class BackupEngine
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin);
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId];
return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []];
}
}
@@ -379,35 +414,6 @@ class BackupEngine
};
}
/**
* Verify required PHP extensions are loaded.
*
* @return true|string True if all ok, or error message string
*/
private function checkRequiredExtensions(): true|string
{
$required = [
'zip' => 'ext-zip (required for archive creation)',
'pdo' => 'ext-pdo (required for database operations)',
'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)',
'mbstring' => 'ext-mbstring (required for binary-safe operations)',
];
$missing = [];
foreach ($required as $ext => $label) {
if (!extension_loaded($ext)) {
$missing[] = $label;
}
}
if (!empty($missing)) {
return 'Missing PHP extensions: ' . implode(', ', $missing);
}
return true;
}
/**
* Create the appropriate archiver based on the archive format.
*/
@@ -416,7 +422,7 @@ class BackupEngine
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
default => new ZipArchiver(),
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
};
}
@@ -487,6 +493,7 @@ class BackupEngine
$name = $zip->getNameIndex($i);
if ($name === false) {
$this->log('WARNING: Could not read file at index ' . $i . ' during encryption — file may remain unencrypted');
continue;
}
@@ -60,7 +60,9 @@ class DatabaseDumper
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
$output[] = '-- Server: ' . $db->getServerType();
$output[] = '-- Database: ' . $db->getName();
$output[] = '-- Prefix: ' . $prefix;
$output[] = '-- Original Prefix: ' . $prefix;
$output[] = '-- Abstract Prefix: #__';
$output[] = '-- Note: Table names use #__ placeholder. Replace with your prefix on restore.';
$output[] = '';
$output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";';
$output[] = 'SET time_zone = "+00:00";';
@@ -90,7 +92,7 @@ class DatabaseDumper
$this->tablesCount++;
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
if ($skipData) {
$output[] = '-- (data excluded)';
@@ -112,8 +114,11 @@ class DatabaseDumper
continue;
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix with #__ in CREATE TABLE
// output — covers the table itself and FK REFERENCES to other tables
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
}
@@ -160,7 +165,7 @@ class DatabaseDumper
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -214,6 +219,138 @@ class DatabaseDumper
return false;
}
/**
* Dump all database tables directly to a file, streaming row by row.
* Avoids loading the entire dump into RAM.
*
* @param string $filePath Absolute path to write the SQL file
*
* @return int Size of the dump file in bytes
*/
public function dumpToFile(string $filePath): int
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
$fp = fopen($filePath, 'w');
if ($fp === false) {
throw new \RuntimeException('Cannot open dump file for writing: ' . $filePath);
}
fwrite($fp, "-- MokoSuiteBackup Database Dump\n");
fwrite($fp, "-- Generated: " . date('Y-m-d H:i:s') . "\n");
fwrite($fp, "-- Server: " . $db->getServerType() . "\n");
fwrite($fp, "-- Database: " . $db->getName() . "\n");
fwrite($fp, "-- Original Prefix: " . $prefix . "\n");
fwrite($fp, "-- Abstract Prefix: #__\n");
fwrite($fp, "-- Note: Table names use #__ placeholder. Replace with your prefix on restore.\n\n");
fwrite($fp, "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n");
fwrite($fp, "SET time_zone = \"+00:00\";\n\n");
// Get all tables with the site prefix
$tables = $db->getTableList();
$siteTables = [];
foreach ($tables as $table) {
if (str_starts_with($table, $prefix)) {
$siteTables[] = $table;
}
}
foreach ($siteTables as $table) {
$abstractName = '#__' . substr($table, strlen($prefix));
if ($this->isExcludedBoth($abstractName, $table)) {
continue;
}
$skipData = $this->isExcludedDataOnly($abstractName, $table);
$skipStructure = $this->isExcludedStructureOnly($abstractName, $table);
$this->tablesCount++;
fwrite($fp, "-- --------------------------------------------------------\n");
fwrite($fp, "-- Table: " . $abstractName . "\n");
if ($skipData) {
fwrite($fp, "-- (data excluded)\n");
}
if ($skipStructure) {
fwrite($fp, "-- (structure excluded)\n");
}
fwrite($fp, "-- --------------------------------------------------------\n\n");
if (!$skipStructure) {
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
if (!$createRow || empty($createRow[1])) {
continue;
}
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
fwrite($fp, 'DROP TABLE IF EXISTS `' . $abstractName . "`;\\n");
fwrite($fp, $createSql . ";\n\n");
}
if ($skipData) {
fwrite($fp, "\n");
continue;
}
$db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table));
$rowCount = (int) $db->loadResult();
if ($rowCount === 0) {
fwrite($fp, "-- (empty table)\n\n");
continue;
}
$chunkSize = 500;
for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) {
$db->setQuery(
$db->getQuery(true)
->select('*')
->from($db->quoteName($table)),
$offset,
$chunkSize
);
$rows = $db->loadAssocList();
if (empty($rows)) {
break;
}
foreach ($rows as $row) {
$values = [];
foreach ($row as $value) {
if ($value === null) {
$values[] = 'NULL';
} else {
$values[] = $db->quote($value);
}
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
fwrite($fp, 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ");\n");
}
}
fwrite($fp, "\n");
}
fclose($fp);
return filesize($filePath) ?: 0;
}
public function getTablesCount(): int
{
return $this->tablesCount;
@@ -87,11 +87,8 @@ class DatabaseImporter
continue;
}
// Replace the prefix from the dump with the current site prefix.
// The dump uses real table names (with the original prefix), but
// if restoring to a site with a different prefix we need to handle it.
// Our DatabaseDumper uses real names, so no replacement needed
// for same-site restores.
// Replace abstract #__ prefix with the current site's prefix
$statement = str_replace('#__', $prefix, $statement);
try {
$db->setQuery($statement);
@@ -110,6 +107,8 @@ class DatabaseImporter
$remaining = trim($currentStatement);
if (!empty($remaining)) {
$remaining = str_replace('#__', $prefix, $remaining);
try {
$db->setQuery($remaining);
$db->execute();
@@ -206,6 +206,11 @@ class JpaUnarchiver
}
}
// Path traversal protection: reject absolute paths and directory traversal
if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) {
return; // skip malicious entry
}
// Is this a directory?
if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) {
$dirPath = $this->outputDir . '/' . $path;
@@ -228,6 +233,24 @@ class JpaUnarchiver
// Write file
$fullPath = $this->outputDir . '/' . $path;
// Verify resolved path stays within output directory
$realOutput = realpath($this->outputDir);
if ($realOutput !== false) {
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
$realDest = realpath($parentDir);
if ($realDest === false || !str_starts_with($realDest, $realOutput)) {
return; // path escapes staging directory
}
}
$parentDir = dirname($fullPath);
if (!is_dir($parentDir)) {
@@ -109,6 +109,56 @@ if (empty($_SESSION['restore_token'])) {
$token = $_SESSION['restore_token'];
// ── Security Verification ───────────────────────────────────────────
// Write a security file to the web root with a random code.
// The user must read the code from the file and enter it in the browser
// to prove they have filesystem access before any restore actions are allowed.
$securityFile = RESTORE_DIR . '/.mokorestore-security.php';
$securityCode = $_SESSION['security_code'] ?? '';
if (empty($securityCode)) {
$securityCode = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$_SESSION['security_code'] = $securityCode;
$_SESSION['security_verified'] = false;
// Write security file with the code
$securityContent = "<?php die('MokoRestore Security Code: " . $securityCode . "'); ?>\n"
. "MokoRestore Security Verification\n"
. "==================================\n"
. "Code: " . $securityCode . "\n"
. "Enter this code in the MokoRestore browser interface to proceed.\n"
. "This file will be deleted automatically after verification.\n";
if (file_put_contents($securityFile, $securityContent) === false) {
// Cannot write security file — skip verification to avoid locking user out
$_SESSION['security_verified'] = true;
error_log('MokoRestore: Cannot write security file — verification skipped (check directory permissions)');
}
}
// Handle security code verification via POST
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'verify_security') {
header('Content-Type: application/json; charset=utf-8');
$inputCode = strtoupper(trim($_POST['security_code'] ?? ''));
if ($inputCode === $securityCode) {
$_SESSION['security_verified'] = true;
// Delete the security file
if (is_file($securityFile)) {
@unlink($securityFile);
}
echo json_encode(['success' => true, 'message' => 'Security verified']);
} else {
echo json_encode(['success' => false, 'message' => 'Incorrect security code. Check the file: .mokorestore-security.php']);
}
exit;
}
// Block all other actions until security is verified
$securityVerified = !empty($_SESSION['security_verified']);
// ── AJAX Handler ────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json; charset=utf-8');
@@ -118,6 +168,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
exit;
}
if (!$securityVerified) {
echo json_encode(['success' => false, 'message' => 'Security verification required. Enter the code from .mokorestore-security.php']);
exit;
}
@set_time_limit(0);
@ini_set('max_execution_time', '0');
@ini_set('memory_limit', '512M');
@@ -248,6 +303,20 @@ function actionExtract(array $data): array
$zip->setPassword($password);
}
// Validate all entries before extraction (path traversal protection)
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if ($entryName === false) {
continue;
}
if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) {
$zip->close();
throw new RuntimeException('Archive contains unsafe path: ' . $entryName);
}
}
if (!$zip->extractTo(RESTORE_DIR)) {
$zip->close();
throw new RuntimeException(
@@ -348,7 +417,12 @@ function actionDatabase(array $data): array
$pdo->exec("SET time_zone = '+00:00'");
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
$sql = file_get_contents($sqlFile);
$sql = file_get_contents($sqlFile);
$prefix = getValidatedPrefix($data);
// Replace abstract #__ prefix with the user's target prefix
$sql = str_replace('#__', $prefix, $sql);
$parts = explode(";\n", $sql);
$statements = 0;
$errors = 0;
@@ -675,7 +749,7 @@ HTACCESS;
function getValidatedPrefix(array $data): string
{
$prefix = getValidatedPrefix($data);
$prefix = trim($data['db_prefix'] ?? 'moko_');
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
@@ -710,7 +784,7 @@ function actionListAdmins(array $data): array
function actionResetAdmin(array $data): array
{
$pdo = getDbConnection($data);
$prefix = $data['db_prefix'] ?? 'moko_';
$prefix = getValidatedPrefix($data);
$userId = (int) ($data['admin_id'] ?? 0);
$password = $data['new_password'] ?? '';
@@ -981,8 +1055,33 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
<div class="mr-panel <?php echo $securityVerified ? '' : 'visible'; ?>" id="panel0">
<h2>Security Verification</h2>
<p class="mr-desc">To prevent unauthorized access, enter the security code from the file <code>.mokorestore-security.php</code> in your site root.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128274;</span> How to find the code
</div>
<ol style="margin:0;padding-left:1.25rem;color:#475569;font-size:0.9rem;line-height:1.6">
<li>Connect to your server via FTP, SSH, or file manager</li>
<li>Open <code>.mokorestore-security.php</code> in the site root directory</li>
<li>Copy the 8-character code and enter it below</li>
</ol>
</div>
<div class="mr-field">
<label>Security Code</label>
<input type="text" id="securityCode" placeholder="e.g. A1B2C3D4" maxlength="8" style="text-transform:uppercase;letter-spacing:0.2em;font-family:monospace;font-size:1.1rem;text-align:center">
</div>
<div class="mr-status" id="securityStatus"></div>
<div class="mr-actions">
<span></span>
<button class="mr-btn mr-btn-primary" id="btnVerify" onclick="verifySecurity()">Verify &amp; Continue</button>
</div>
</div>
<!-- Step 1: Pre-flight Checks -->
<div class="mr-panel visible" id="panel1">
<div class="mr-panel <?php echo $securityVerified ? 'visible' : ''; ?>" id="panel1">
<h2>Pre-Installation Checks</h2>
<p class="mr-desc">Verify your server meets the requirements for Joomla and MokoRestore.</p>
<ul class="mr-checks" id="checkList"></ul>
@@ -1223,6 +1322,35 @@ function setBtnLoading(btn, loading) {
}
// Step 1
async function verifySecurity() {
const btn = document.getElementById('btnVerify');
setBtnLoading(btn, true);
const code = document.getElementById('securityCode').value.trim();
if (!code) {
setStatus('securityStatus', 'Please enter the security code', 'error');
setBtnLoading(btn, false);
return;
}
const form = new FormData();
form.append('action', 'verify_security');
form.append('security_code', code);
form.append('token', TOKEN);
const resp = await fetch('', { method: 'POST', body: form });
const r = await resp.json();
setBtnLoading(btn, false);
if (r.success) {
setStatus('securityStatus', 'Verified!', 'success');
document.getElementById('panel0').classList.remove('visible');
document.getElementById('panel1').classList.add('visible');
} else {
setStatus('securityStatus', r.message, 'error');
}
}
async function runPreflight() {
const btn = document.getElementById('btnCheck');
setBtnLoading(btn, true);
@@ -169,6 +169,12 @@ class NotificationSender
return false;
}
if (!function_exists('curl_init')) {
error_log('MokoSuiteBackup: ntfy notifications require ext-curl');
return false;
}
try {
$config = Factory::getApplication()->getConfig();
$siteName = $config->get('sitename', 'Joomla Site');
@@ -219,7 +225,7 @@ class NotificationSender
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . $response);
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200));
return false;
}
@@ -0,0 +1,305 @@
<?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
*
* Pre-flight validation for backup operations.
*
* Runs before any backup record is created, catching problems early
* with clear messages instead of failing mid-backup. Returns a result
* with errors (blockers) and warnings (informational).
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class PreflightCheck
{
/** @var string[] Fatal issues that prevent backup from starting */
private array $errors = [];
/** @var string[] Non-fatal issues the user should know about */
private array $warnings = [];
/**
* Run all pre-flight checks for a backup profile.
*
* @param int $profileId Profile to validate
*
* @return array{pass: bool, errors: string[], warnings: string[]}
*/
public function run(int $profileId): array
{
try {
$db = Factory::getDbo();
} catch (\Exception $e) {
$this->errors[] = 'Cannot connect to database: ' . $e->getMessage();
return $this->result();
}
// Load profile
try {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . (int) $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
} catch (\Exception $e) {
$this->errors[] = 'Cannot load profile: ' . $e->getMessage();
return $this->result();
}
if (!$profile) {
$this->errors[] = 'Profile not found: #' . $profileId;
return $this->result();
}
if (!$profile->published) {
$this->errors[] = 'Profile is unpublished: ' . $profile->title;
return $this->result();
}
$this->checkPhpExtensions($profile);
$this->checkBackupDirectory($profile);
$this->checkDiskSpace($profile, $db);
$this->checkRunningBackup($profile, $db);
$this->checkExcludedTables($profile, $db);
$this->checkRemoteCredentials($profile);
return $this->result();
}
/**
* Check that required PHP extensions are loaded.
*/
private function checkPhpExtensions(object $profile): void
{
$required = ['pdo', 'pdo_mysql', 'mbstring'];
// ZIP is required unless using tar.gz
$format = $profile->archive_format ?? 'zip';
if ($format === 'zip') {
$required[] = 'zip';
}
foreach ($required as $ext) {
if (!extension_loaded($ext)) {
$this->errors[] = 'Missing required PHP extension: ext-' . $ext;
}
}
// curl is only needed for remote upload and ntfy notifications
$needsCurl = ($profile->remote_storage ?? 'none') !== 'none'
|| !empty($profile->ntfy_topic);
if ($needsCurl && !extension_loaded('curl')) {
$this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work';
}
}
/**
* Check that the backup directory exists and is writable.
*/
private function checkBackupDirectory(object $profile): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
// Resolve placeholders using a temporary resolver
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir)) {
$this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir
. ' — directory cannot be validated until backup runs';
return;
}
if (!is_dir($resolvedDir)) {
// Try to create it
if (!@mkdir($resolvedDir, 0755, true)) {
$lastError = error_get_last();
$reason = $lastError['message'] ?? 'unknown reason';
$this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir
. ' (' . $reason . ')';
return;
}
}
if (!is_writable($resolvedDir)) {
$this->errors[] = 'Backup directory is not writable: ' . $resolvedDir;
}
}
/**
* Check available disk space against the last backup size + 20% buffer.
* Skipped if no previous backup exists for this profile.
*/
private function checkDiskSpace(object $profile, object $db): void
{
$configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER;
$resolver = new PlaceholderResolver($profile);
$resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir));
if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) {
return;
}
// Find last successful backup size for this profile
$query = $db->getQuery(true)
->select($db->quoteName('total_size'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('total_size') . ' > 0')
->order($db->quoteName('backupstart') . ' DESC');
$db->setQuery($query, 0, 1);
$lastSize = (int) $db->loadResult();
if ($lastSize === 0) {
// No previous backup — skip disk space check
return;
}
$requiredBytes = (int) ($lastSize * 1.2); // 20% buffer
$freeBytes = @disk_free_space($resolvedDir);
if ($freeBytes === false) {
$this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir;
return;
}
if ($freeBytes < $requiredBytes) {
$freeMB = number_format($freeBytes / 1048576, 1);
$neededMB = number_format($requiredBytes / 1048576, 1);
$this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed'
. ' (based on last backup + 20% buffer)';
}
}
/**
* Check if another backup is already running for this profile.
*/
private function checkRunningBackup(object $profile, object $db): void
{
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id)
->where($db->quoteName('status') . ' = ' . $db->quote('running'));
$db->setQuery($query);
$running = (int) $db->loadResult();
if ($running > 0) {
$this->errors[] = 'Another backup is already running for profile: ' . $profile->title
. ' — wait for it to finish or delete the stale record';
}
}
/**
* Check that excluded tables actually exist in the database.
* Missing tables are warnings, not errors — the profile may have
* been copied from another site or a table may have been removed.
*/
private function checkExcludedTables(object $profile, object $db): void
{
$excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? '');
if (empty($excludeRaw)) {
return;
}
$prefix = $db->getPrefix();
$allTables = array_flip($db->getTableList());
foreach ($excludeRaw as $entry) {
// Strip :data-only / :structure-only suffixes
$tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry);
// Resolve #__ prefix to real prefix
$realName = str_replace('#__', $prefix, $tableName);
if (!isset($allTables[$realName])) {
$this->warnings[] = 'Excluded table does not exist: ' . $tableName
. ' — it will be silently skipped during backup';
}
}
}
/**
* Check that remote storage credentials are minimally configured.
* Does not test the actual connection (too slow for preflight).
*/
private function checkRemoteCredentials(object $profile): void
{
$remote = $profile->remote_storage ?? 'none';
if ($remote === 'none') {
return;
}
switch ($remote) {
case 'ftp':
if (empty($profile->ftp_host)) {
$this->warnings[] = 'FTP host is not configured — remote upload will fail';
}
if (empty($profile->ftp_username)) {
$this->warnings[] = 'FTP username is not configured — remote upload will fail';
}
break;
case 's3':
if (empty($profile->s3_bucket)) {
$this->warnings[] = 'S3 bucket is not configured — remote upload will fail';
}
if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) {
$this->warnings[] = 'S3 credentials are not configured — remote upload will fail';
}
break;
case 'google_drive':
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
}
if (empty($profile->gdrive_refresh_token)) {
$this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail';
}
break;
}
}
/**
* Build the result array.
*/
private function result(): array
{
return [
'pass' => empty($this->errors),
'errors' => $this->errors,
'warnings' => $this->warnings,
];
}
}
@@ -76,8 +76,9 @@ class RestoreEngine
return ['success' => false, 'message' => 'Backup archive not found: ' . $archivePath];
}
// Create staging directory
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $record->tag;
// Create staging directory (sanitize tag to prevent path traversal)
$safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore');
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag;
if (is_dir($this->stagingDir)) {
$this->recursiveDelete($this->stagingDir);
@@ -190,6 +191,20 @@ class RestoreEngine
$this->log('Decryption password set');
}
// Validate all entries before extraction (path traversal protection)
for ($i = 0; $i < $zip->numFiles; $i++) {
$entryName = $zip->getNameIndex($i);
if ($entryName === false) {
continue;
}
if (str_contains($entryName, '../') || str_contains($entryName, '..\\') || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) {
$zip->close();
throw new \RuntimeException('Archive contains unsafe path: ' . $entryName);
}
}
if (!$zip->extractTo($this->stagingDir)) {
$zip->close();
@@ -209,6 +224,18 @@ class RestoreEngine
private function extractTarGz(string $archivePath): void
{
$phar = new \PharData($archivePath);
// Validate all entries before extraction (path traversal protection)
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
$entryName = $entry->getPathname();
// PharData paths are prefixed with phar:// — extract the relative part
$relative = substr($entryName, strlen('phar://' . $archivePath) + 1);
if (str_contains($relative, '../') || str_contains($relative, '..\\') || str_starts_with($relative, '/') || str_starts_with($relative, '\\')) {
throw new \RuntimeException('Archive contains unsafe path: ' . $relative);
}
}
$phar->extractTo($this->stagingDir, null, true);
$this->log('Extracted tar.gz archive');
}
@@ -114,19 +114,28 @@ class S3Uploader implements RemoteUploaderInterface
*/
private function singleUpload(string $localPath, string $objectKey): void
{
$url = $this->getObjectUrl($objectKey);
$fileContent = file_get_contents($localPath);
$contentHash = hash('sha256', $fileContent);
$url = $this->getObjectUrl($objectKey);
$fileSize = filesize($localPath);
// Stream file to compute SHA-256 without loading into RAM
$contentHash = hash_file('sha256', $localPath);
$headers = $this->signRequest('PUT', $url, $contentHash, [
'Content-Type' => 'application/zip',
'Content-Length' => (string) strlen($fileContent),
'Content-Length' => (string) $fileSize,
]);
$fp = fopen($localPath, 'rb');
if ($fp === false) {
throw new \RuntimeException('Cannot open file for upload: ' . $localPath);
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $fileContent,
CURLOPT_PUT => true,
CURLOPT_INFILE => $fp,
CURLOPT_INFILESIZE => $fileSize,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 600,
@@ -135,6 +144,8 @@ class S3Uploader implements RemoteUploaderInterface
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
fclose($fp);
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
@@ -0,0 +1,238 @@
<?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
*
* Snapshot engine — creates lightweight JSON snapshots of specific content
* types (articles, categories, modules) without touching the filesystem.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class SnapshotEngine
{
private array $log = [];
/** Content type => tables mapping */
private const TYPE_TABLES = [
'articles' => [
'#__content',
'#__content_frontpage',
],
'categories' => [
'#__categories',
],
'modules' => [
'#__modules',
'#__modules_menu',
],
];
/** Related tables always captured when articles are included */
private const ARTICLE_RELATED = [
'#__workflow_associations',
'#__contentitem_tag_map',
];
/**
* Create a snapshot of selected content types.
*
* @param array $contentTypes Types to snapshot: articles, categories, modules
* @param string $description User-provided description
*
* @return array{success: bool, message: string, id?: int}
*/
public function create(array $contentTypes, string $description = ''): array
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
if (empty($contentTypes)) {
return ['success' => false, 'message' => 'No content types selected'];
}
$validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']);
if (empty($validTypes)) {
return ['success' => false, 'message' => 'No valid content types selected'];
}
$this->log('Starting snapshot: ' . implode(', ', $validTypes));
try {
$data = [
'version' => 1,
'created' => date('Y-m-d H:i:s'),
'content_types' => array_values($validTypes),
'tables' => [],
];
$counts = [
'articles' => 0,
'categories' => 0,
'modules' => 0,
];
// Dump each selected content type
foreach ($validTypes as $type) {
foreach (self::TYPE_TABLES[$type] as $abstractTable) {
$realTable = str_replace('#__', $prefix, $abstractTable);
$rows = $this->dumpTable($db, $realTable, $abstractTable, $type);
$data['tables'][$abstractTable] = $rows;
$this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows');
}
}
// Capture related tables for articles
if (in_array('articles', $validTypes)) {
$rows = $this->dumpFilteredTable(
$db,
str_replace('#__', $prefix, '#__workflow_associations'),
'#__workflow_associations',
'extension',
'com_content.article'
);
$data['tables']['#__workflow_associations'] = $rows;
$this->log(' #__workflow_associations: ' . count($rows) . ' rows');
$rows = $this->dumpTagMap($db, $prefix);
$data['tables']['#__contentitem_tag_map'] = $rows;
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
}
// Count items
if (in_array('articles', $validTypes)) {
$counts['articles'] = count($data['tables']['#__content'] ?? []);
}
if (in_array('categories', $validTypes)) {
$counts['categories'] = count($data['tables']['#__categories'] ?? []);
}
if (in_array('modules', $validTypes)) {
$counts['modules'] = count($data['tables']['#__modules'] ?? []);
}
// Write JSON file to backup directory
$backupDir = BackupDirectory::getDefaultAbsolute();
BackupDirectory::ensureReady($backupDir);
$filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json';
$filePath = $backupDir . '/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new \RuntimeException('Failed to encode snapshot data as JSON');
}
if (file_put_contents($filePath, $json) === false) {
throw new \RuntimeException('Failed to write snapshot file: ' . $filePath);
}
$fileSize = strlen($json);
$this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)');
// Create database record
$now = Factory::getDate()->toSql();
$userId = Factory::getApplication()->getIdentity()->id ?? 0;
$record = (object) [
'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes),
'content_types' => json_encode(array_values($validTypes)),
'status' => 'complete',
'articles_count' => $counts['articles'],
'categories_count' => $counts['categories'],
'modules_count' => $counts['modules'],
'data_file' => $filePath,
'data_size' => $fileSize,
'log' => implode("\n", $this->log),
'created' => $now,
'created_by' => $userId,
];
$db->insertObject('#__mokosuitebackup_snapshots', $record, 'id');
$this->log('Snapshot record created: ID ' . $record->id);
return [
'success' => true,
'message' => sprintf(
'Snapshot created: %d articles, %d categories, %d modules',
$counts['articles'],
$counts['categories'],
$counts['modules']
),
'id' => $record->id,
];
} catch (\Exception $e) {
$this->log('FATAL: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Snapshot failed: ' . $e->getMessage(),
'log' => implode("\n", $this->log),
];
}
}
/**
* Dump all rows from a table.
*/
private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array
{
$query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable));
// Filter categories to com_content only
if ($abstractTable === '#__categories' && $type === 'categories') {
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
}
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
/**
* Dump rows from a table filtered by a column value.
*/
private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($realTable))
->where($db->quoteName($column) . ' = ' . $db->quote($value));
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
/**
* Dump tag map entries for com_content items.
*/
private function dumpTagMap(object $db, string $prefix): array
{
$table = $prefix . 'contentitem_tag_map';
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($table))
->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
}
@@ -0,0 +1,324 @@
<?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
*
* Restores content from a snapshot JSON file.
*
* Two restore modes:
* - replace: Truncates target tables then inserts all snapshot rows (clean slate)
* - merge: Upserts by primary key — updates existing rows, inserts new ones
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
class SnapshotRestoreEngine
{
private array $log = [];
/** Primary key columns for each table */
private const PRIMARY_KEYS = [
'#__content' => 'id',
'#__content_frontpage' => 'content_id',
'#__categories' => 'id',
'#__workflow_associations' => 'item_id',
'#__contentitem_tag_map' => null, // composite key, handled specially
'#__modules' => 'id',
'#__modules_menu' => null, // composite key, handled specially
];
/**
* Restore from a snapshot record.
*
* @param int $snapshotId Snapshot record ID
* @param string $mode 'replace' or 'merge'
* @param array $contentTypes Which types to restore (empty = all from snapshot)
*
* @return array{success: bool, message: string, log?: string}
*/
public function restore(int $snapshotId, string $mode = 'replace', array $contentTypes = []): array
{
if (!@set_time_limit(0)) {
$this->log('WARNING: Could not disable time limit — large restores may timeout');
}
if (!@ini_set('memory_limit', '512M')) {
$this->log('WARNING: Could not increase memory limit to 512M');
}
if (!in_array($mode, ['replace', 'merge'])) {
return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode];
}
$db = Factory::getDbo();
// Load snapshot record
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $snapshotId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
}
if ($record->status !== 'complete') {
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
}
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
}
$this->log('Loading snapshot file: ' . basename($record->data_file));
$json = file_get_contents($record->data_file);
if ($json === false) {
return ['success' => false, 'message' => 'Cannot read snapshot file'];
}
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
}
if (!is_array($data) || empty($data['tables'])) {
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
}
$snapshotTypes = $data['content_types'] ?? [];
$this->log('Snapshot contains: ' . implode(', ', $snapshotTypes));
$this->log('Restore mode: ' . $mode);
// Determine which types to restore
if (!empty($contentTypes)) {
$restoreTypes = array_intersect($contentTypes, $snapshotTypes);
} else {
$restoreTypes = $snapshotTypes;
}
if (empty($restoreTypes)) {
return ['success' => false, 'message' => 'No matching content types to restore'];
}
$this->log('Restoring types: ' . implode(', ', $restoreTypes));
$prefix = $db->getPrefix();
$totalRows = 0;
try {
$db->transactionStart();
// Build list of tables to restore based on selected types
$tablesToRestore = $this->getTablesToRestore($restoreTypes);
foreach ($tablesToRestore as $abstractTable) {
if (!isset($data['tables'][$abstractTable])) {
$this->log(' Skipping ' . $abstractTable . ' (not in snapshot)');
continue;
}
$rows = $data['tables'][$abstractTable];
$realTable = str_replace('#__', $prefix, $abstractTable);
if ($mode === 'replace') {
$rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows);
} else {
$rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows);
}
$totalRows += $rowCount;
$this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored');
}
$db->transactionCommit();
$this->log('Restore complete: ' . $totalRows . ' total rows');
return [
'success' => true,
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
'log' => implode("\n", $this->log),
];
} catch (\Throwable $e) {
try {
$db->transactionRollback();
$this->log('Transaction rolled back');
} catch (\Exception $rollbackEx) {
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
}
$this->log('FATAL: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Restore failed: ' . $e->getMessage(),
'log' => implode("\n", $this->log),
];
}
}
/**
* Replace mode: delete existing rows, then insert all snapshot rows.
*/
private function restoreReplace(object $db, string $realTable, string $abstractTable, array $rows): int
{
// Use DELETE instead of TRUNCATE to stay within transaction
$this->truncateFiltered($db, $realTable, $abstractTable, $rows);
$count = 0;
foreach ($rows as $row) {
$obj = (object) $row;
$db->insertObject($realTable, $obj);
$count++;
}
return $count;
}
/**
* Merge mode: upsert rows by primary key.
*/
private function restoreMerge(object $db, string $realTable, string $abstractTable, array $rows): int
{
$pk = self::PRIMARY_KEYS[$abstractTable] ?? null;
$count = 0;
foreach ($rows as $row) {
$obj = (object) $row;
if ($pk !== null && isset($row[$pk])) {
// Check if row exists
$exists = $db->setQuery(
$db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName($realTable))
->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk]))
)->loadResult();
if ($exists) {
$db->updateObject($realTable, $obj, $pk);
} else {
$db->insertObject($realTable, $obj);
}
} else {
// Composite key tables — insert, skip genuine duplicates
try {
$db->insertObject($realTable, $obj);
} catch (\Exception $e) {
if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) {
$this->log(' Skipped duplicate in ' . $abstractTable);
continue;
}
throw $e;
}
}
$count++;
}
return $count;
}
/**
* Delete rows from a table, scoping to relevant content only.
*
* Shared tables (#__categories, #__modules, etc.) are filtered so
* only the rows belonging to our content types are deleted — never
* the entire table.
*/
private function truncateFiltered(object $db, string $realTable, string $abstractTable, array $rows): void
{
$query = $db->getQuery(true)->delete($db->quoteName($realTable));
switch ($abstractTable) {
case '#__categories':
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
break;
case '#__workflow_associations':
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content.article'));
break;
case '#__contentitem_tag_map':
$query->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
break;
case '#__modules':
// Only delete modules that exist in the snapshot — never wipe all site modules
$ids = array_filter(array_column($rows, 'id'));
if (empty($ids)) {
return;
}
$ids = array_map('intval', $ids);
$query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
break;
case '#__modules_menu':
// Only delete menu assignments for modules in the snapshot
$moduleIds = array_filter(array_column($rows, 'moduleid'));
if (empty($moduleIds)) {
return;
}
$moduleIds = array_map('intval', array_unique($moduleIds));
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
break;
// #__content and #__content_frontpage are fully owned by com_content
default:
break;
}
$db->setQuery($query);
$db->execute();
}
/**
* Build list of abstract table names for the given content types.
*/
private function getTablesToRestore(array $types): array
{
$tables = [];
if (in_array('articles', $types)) {
$tables[] = '#__content';
$tables[] = '#__content_frontpage';
$tables[] = '#__workflow_associations';
$tables[] = '#__contentitem_tag_map';
}
if (in_array('categories', $types)) {
$tables[] = '#__categories';
}
if (in_array('modules', $types)) {
$tables[] = '#__modules';
$tables[] = '#__modules_menu';
}
return array_unique($tables);
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
}
@@ -32,6 +32,18 @@ class SteppedBackupEngine
*/
public function init(int $profileId, string $description = '', string $origin = 'backend'): array
{
// Run pre-flight checks before creating any backup record
$preflight = new PreflightCheck();
$preflightResult = $preflight->run($profileId);
if (!$preflightResult['pass']) {
return [
'error' => true,
'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']),
'warnings' => $preflightResult['warnings'],
];
}
$db = Factory::getDbo();
// Load profile
@@ -43,7 +55,7 @@ class SteppedBackupEngine
$profile = $db->loadObject();
if (!$profile) {
return ['error' => true, 'message' => 'Profile not found: ' . $profileId];
return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []];
}
// Create session
@@ -130,6 +142,11 @@ class SteppedBackupEngine
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
$session->log('Backup initialized: ' . $session->description);
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
// Log any preflight warnings into the session
foreach ($preflightResult['warnings'] as $warning) {
$session->log('PREFLIGHT WARNING: ' . $warning);
}
$session->statusMessage = 'Initialized — starting backup...';
$session->save();
@@ -138,6 +155,7 @@ class SteppedBackupEngine
'phase' => $session->phase,
'progress' => $session->getProgress(),
'message' => $session->statusMessage,
'warnings' => $preflightResult['warnings'],
];
}
@@ -220,8 +238,7 @@ class SteppedBackupEngine
$db = Factory::getDbo();
// Dump this single table
$dumper = new DatabaseDumper([]);
$sql = $this->dumpSingleTable($db, $table);
$sql = $this->dumpSingleTable($db, $table);
// Append to a temp SQL file that will be added to ZIP in finalize
$sqlFile = $session->archivePath . '.sql';
@@ -234,8 +251,9 @@ class SteppedBackupEngine
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
. "SET time_zone = \"+00:00\";\n\n";
if (file_put_contents($sqlFile, $header) === false) {
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile);
}
$flags = FILE_APPEND;
}
@@ -434,12 +452,14 @@ class SteppedBackupEngine
}
$totalSize = is_file($session->archivePath) ? filesize($session->archivePath) : 0;
$checksum = is_file($session->archivePath) ? hash_file('sha256', $session->archivePath) : '';
$update = (object) [
'id' => $session->recordId,
'status' => 'complete',
'backupend' => date('Y-m-d H:i:s'),
'total_size' => $totalSize,
'checksum' => $checksum,
'log' => $logContent,
];
@@ -471,7 +491,7 @@ class SteppedBackupEngine
NotificationSender::send($profile, $record, true, $logContent);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
}
}
@@ -529,13 +549,16 @@ class SteppedBackupEngine
*/
private function dumpSingleTable(object $db, string $table): string
{
$prefix = $db->getPrefix();
$abstractName = '#__' . substr($table, strlen($prefix));
$output = [];
$output[] = '-- --------------------------------------------------------';
$output[] = '-- Table: ' . $table;
$output[] = '-- Table: ' . $abstractName;
$output[] = '-- --------------------------------------------------------';
$output[] = '';
// CREATE TABLE
// CREATE TABLE — replace live prefix with #__
$db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table));
$createRow = $db->loadRow();
@@ -543,8 +566,10 @@ class SteppedBackupEngine
return '';
}
$output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';';
$output[] = $createRow[1] . ';';
// Replace all occurrences of the live prefix — covers FK REFERENCES too
$createSql = str_replace('`' . $prefix, '`#__', $createRow[1]);
$output[] = 'DROP TABLE IF EXISTS `' . $abstractName . '`;';
$output[] = $createSql . ';';
$output[] = '';
// Data in chunks
@@ -580,7 +605,7 @@ class SteppedBackupEngine
}
$columns = array_map([$db, 'quoteName'], array_keys($row));
$output[] = 'INSERT INTO ' . $db->quoteName($table)
$output[] = 'INSERT INTO `' . $abstractName . '`'
. ' (' . implode(', ', $columns) . ')'
. ' VALUES (' . implode(', ', $values) . ');';
}
@@ -47,12 +47,14 @@ class TarGzArchiver implements ArchiverInterface
public function close(): void
{
// Compress the .tar to .tar.gz
$this->tar->compress(\Phar::GZ);
// Remove the uncompressed .tar
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
try {
// Compress the .tar to .tar.gz
$this->tar->compress(\Phar::GZ);
} finally {
// Always remove the uncompressed .tar, even if compress() fails
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
}
}
@@ -0,0 +1,203 @@
<?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 IP whitelist management.
* Shows current user's IP and presents entries as a table.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class IpWhitelistField extends FormField
{
protected $type = 'IpWhitelist';
protected function getInput(): string
{
$value = trim($this->value ?? '');
$id = $this->id;
$name = $this->name;
$currentIp = $this->getCurrentIp();
$ips = array_filter(array_map('trim', explode(',', $value)));
$html = '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="' . htmlspecialchars($value) . '">';
// Current IP display
$html .= '<div class="alert alert-info py-2 mb-2">'
. '<span class="icon-location" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_YOUR_IP') . ': '
. '<strong class="font-monospace">' . htmlspecialchars($currentIp) . '</strong>';
$alreadyAdded = in_array($currentIp, $ips);
if (!$alreadyAdded) {
$html .= ' <button type="button" class="btn btn-sm btn-outline-primary ms-2"'
. ' onclick="mokoIpAdd(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($currentIp) . '\')">'
. '<span class="icon-plus" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_ADD_CURRENT_IP')
. '</button>';
} else {
$html .= ' <span class="badge bg-success ms-2">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_INCLUDED') . '</span>';
}
$html .= '</div>';
// IP table
$html .= '<table class="table table-sm table-bordered" id="' . htmlspecialchars($id) . '-table">';
$html .= '<thead><tr>'
. '<th>' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADDRESS') . '</th>'
. '<th class="w-10 text-center">' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_REMOVE') . '</th>'
. '</tr></thead>';
$html .= '<tbody>';
if (empty($ips)) {
$html .= '<tr class="moko-ip-empty"><td colspan="2" class="text-muted text-center">'
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE')
. '</td></tr>';
} else {
foreach ($ips as $ip) {
$html .= '<tr>'
. '<td class="font-monospace">' . htmlspecialchars($ip) . '</td>'
. '<td class="text-center">'
. '<button type="button" class="btn btn-sm btn-outline-danger"'
. ' onclick="mokoIpRemove(\'' . htmlspecialchars($id) . '\', \'' . htmlspecialchars($ip) . '\')">'
. '<span class="icon-times" aria-hidden="true"></span>'
. '</button></td>'
. '</tr>';
}
}
$html .= '</tbody></table>';
// Add custom IP
$html .= '<div class="input-group input-group-sm" style="max-width:350px;">';
$html .= '<input type="text" class="form-control font-monospace" id="' . htmlspecialchars($id) . '-new"'
. ' placeholder="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER') . '"'
. ' pattern="[0-9a-fA-F.:\/]+">';
$html .= '<button type="button" class="btn btn-outline-secondary"'
. ' onclick="mokoIpAddCustom(\'' . htmlspecialchars($id) . '\')">'
. '<span class="icon-plus" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD')
. '</button>';
$html .= '</div>';
$html .= $this->getScript();
return $html;
}
private function getCurrentIp(): string
{
$app = Factory::getApplication();
// Try standard header first, then forwarded headers
$ip = $app->input->server->getString('REMOTE_ADDR', '');
// Check forwarded headers (common behind reverse proxies)
$forwarded = $app->input->server->getString('HTTP_X_FORWARDED_FOR', '');
if (!empty($forwarded)) {
// Take the first IP in the chain (client IP)
$parts = explode(',', $forwarded);
$candidate = trim($parts[0]);
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
$ip = $candidate;
}
}
return $ip ?: '0.0.0.0';
}
private function getScript(): string
{
$noneText = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE'));
return <<<JS
<script>
function mokoIpGetList(fieldId) {
var val = document.getElementById(fieldId).value.trim();
return val ? val.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
}
function mokoIpSync(fieldId, ips) {
document.getElementById(fieldId).value = ips.join(', ');
mokoIpRebuildTable(fieldId, ips);
}
function mokoIpRebuildTable(fieldId, ips) {
var tbody = document.querySelector('#' + fieldId + '-table tbody');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
if (ips.length === 0) {
var tr = document.createElement('tr');
tr.className = 'moko-ip-empty';
var td = document.createElement('td');
td.colSpan = 2;
td.className = 'text-muted text-center';
td.textContent = {$noneText};
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
ips.forEach(function(ip) {
var tr = document.createElement('tr');
var tdIp = document.createElement('td');
tdIp.className = 'font-monospace';
tdIp.textContent = ip;
tr.appendChild(tdIp);
var tdAct = document.createElement('td');
tdAct.className = 'text-center';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-danger';
btn.onclick = function() { mokoIpRemove(fieldId, ip); };
var span = document.createElement('span');
span.className = 'icon-times';
btn.appendChild(span);
tdAct.appendChild(btn);
tr.appendChild(tdAct);
tbody.appendChild(tr);
});
}
function mokoIpAdd(fieldId, ip) {
var ips = mokoIpGetList(fieldId);
if (ips.indexOf(ip) === -1) {
ips.push(ip);
mokoIpSync(fieldId, ips);
}
}
function mokoIpRemove(fieldId, ip) {
var ips = mokoIpGetList(fieldId).filter(function(i) { return i !== ip; });
mokoIpSync(fieldId, ips);
}
function mokoIpAddCustom(fieldId) {
var input = document.getElementById(fieldId + '-new');
var ip = input.value.trim();
if (!ip) return;
mokoIpAdd(fieldId, ip);
input.value = '';
}
</script>
JS;
}
}
@@ -0,0 +1,184 @@
<?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 the webcron secret word.
* Generates a random default and validates minimum strength.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class WebcronSecretField extends FormField
{
protected $type = 'WebcronSecret';
private const MIN_LENGTH = 16;
private const WEAK_PATTERNS = [
'password', 'secret', '123456', 'admin', 'backup',
'test', 'webcron', 'qwerty', 'letmein', 'welcome',
];
protected function getInput(): string
{
$value = $this->value ?? '';
$id = $this->id;
$name = $this->name;
$maxLength = (int) ($this->element['maxlength'] ?? 64);
$strengthHtml = '';
$strengthClass = 'text-muted';
$strengthText = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
if (!empty($value)) {
$strength = $this->evaluateStrength($value);
$strengthClass = $strength['class'];
$strengthText = $strength['label'];
}
$html = '<div class="input-group">';
$html .= '<input type="text" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="' . htmlspecialchars($value) . '"'
. ' class="form-control" maxlength="' . $maxLength . '"'
. ' autocomplete="off" spellcheck="false"'
. ' onchange="mokoWebcronCheckStrength(this)"'
. ' onkeyup="mokoWebcronCheckStrength(this)">';
$html .= '<button type="button" class="btn btn-outline-secondary" onclick="mokoWebcronGenerate(\'' . htmlspecialchars($id) . '\')"'
. ' title="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE') . '">'
. '<span class="icon-refresh" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE')
. '</button>';
$html .= '</div>';
$html .= '<div id="' . htmlspecialchars($id) . '-strength" class="small mt-1 ' . $strengthClass . '">'
. $strengthText . '</div>';
$html .= $this->getScript();
return $html;
}
private function evaluateStrength(string $value): array
{
$len = strlen($value);
// Check weak patterns
$lower = strtolower($value);
foreach (self::WEAK_PATTERNS as $weak) {
if (str_contains($lower, $weak)) {
return [
'class' => 'text-danger fw-bold',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
];
}
}
if ($len < self::MIN_LENGTH) {
return [
'class' => 'text-danger',
'label' => Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', self::MIN_LENGTH),
];
}
$hasUpper = preg_match('/[A-Z]/', $value);
$hasLower = preg_match('/[a-z]/', $value);
$hasDigit = preg_match('/[0-9]/', $value);
if ($hasUpper && $hasLower && $hasDigit && $len >= 32) {
return [
'class' => 'text-success',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'),
];
}
if ($hasUpper && $hasLower && $hasDigit) {
return [
'class' => 'text-warning',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'),
];
}
return [
'class' => 'text-danger',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
];
}
private function getScript(): string
{
$minLen = self::MIN_LENGTH;
$weakJson = json_encode(self::WEAK_PATTERNS);
$labelNone = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
$labelShort = json_encode(Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', $minLen));
$labelWeak = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'));
$labelOk = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'));
$labelStrong = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'));
return <<<JS
<script>
function mokoWebcronGenerate(fieldId) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var result = '';
var arr = new Uint8Array(32);
(window.crypto || window.msCrypto).getRandomValues(arr);
for (var i = 0; i < 32; i++) {
result += chars.charAt(arr[i] % chars.length);
}
var field = document.getElementById(fieldId);
field.value = result;
mokoWebcronCheckStrength(field);
}
function mokoWebcronCheckStrength(field) {
var val = field.value;
var el = document.getElementById(field.id + '-strength');
var weak = {$weakJson};
var lower = val.toLowerCase();
if (!val) {
el.className = 'small mt-1 text-muted';
el.textContent = '{$labelNone}';
return;
}
for (var i = 0; i < weak.length; i++) {
if (lower.indexOf(weak[i]) !== -1) {
el.className = 'small mt-1 text-danger fw-bold';
el.textContent = {$labelWeak};
return;
}
}
if (val.length < {$minLen}) {
el.className = 'small mt-1 text-danger';
el.textContent = {$labelShort};
return;
}
var hasUpper = /[A-Z]/.test(val);
var hasLower = /[a-z]/.test(val);
var hasDigit = /[0-9]/.test(val);
if (hasUpper && hasLower && hasDigit && val.length >= 32) {
el.className = 'small mt-1 text-success';
el.textContent = {$labelStrong};
} else if (hasUpper && hasLower && hasDigit) {
el.className = 'small mt-1 text-warning';
el.textContent = {$labelOk};
} else {
el.className = 'small mt-1 text-danger';
el.textContent = {$labelWeak};
}
}
</script>
JS;
}
}
@@ -0,0 +1,180 @@
<?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\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
/**
* Lightweight helper for external consumers (bridge plugins, MCP servers, CLI tools)
* to query MokoSuiteBackup status without bootstrapping the full component.
*
* Usage from any Joomla plugin:
* \Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper::getStatusSummary()
*/
class BackupStatusHelper
{
/**
* Check whether MokoSuiteBackup is installed and enabled.
*/
public static function isInstalled(): bool
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('pkg_mokosuitebackup'))
->where($db->quoteName('enabled') . ' = 1');
return (int) $db->setQuery($query)->loadResult() > 0;
}
/**
* Get the latest backup record for a given profile (or any profile).
*
* @param int|null $profileId Limit to a specific profile, or null for any.
* @return object|null Record object or null if no backups exist.
*/
public static function getLatestRecord(?int $profileId = null): ?object
{
$db = Factory::getContainer()->get('DatabaseDriver');
$query = $db->getQuery(true)
->select([
$db->quoteName('r.id'),
$db->quoteName('r.profile_id'),
$db->quoteName('r.description'),
$db->quoteName('r.status'),
$db->quoteName('r.origin'),
$db->quoteName('r.backup_type'),
$db->quoteName('r.archivename'),
$db->quoteName('r.total_size'),
$db->quoteName('r.db_size'),
$db->quoteName('r.files_count'),
$db->quoteName('r.tables_count'),
$db->quoteName('r.backupstart'),
$db->quoteName('r.backupend'),
$db->quoteName('r.filesexist'),
$db->quoteName('r.remote_filename'),
$db->quoteName('r.checksum'),
$db->quoteName('p.title', 'profile_title'),
])
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')')
->order($db->quoteName('r.backupstart') . ' DESC');
if ($profileId !== null) {
$query->where($db->quoteName('r.profile_id') . ' = ' . (int) $profileId);
}
$query->setLimit(1);
$record = $db->setQuery($query)->loadObject();
return $record ?: null;
}
/**
* Get a full status summary for heartbeat payloads.
*
* Returns an array suitable for JSON encoding in bridge heartbeats:
* - latest backup status, time, size, destination
* - total and recent (7-day) backup counts
* - streak of consecutive successes
*
* @return array{installed: bool, latest: ?array, totals: array}
*/
public static function getStatusSummary(): array
{
if (!self::isInstalled()) {
return ['installed' => false, 'latest' => null, 'totals' => []];
}
$db = Factory::getContainer()->get('DatabaseDriver');
// Latest completed/failed backup
$latest = self::getLatestRecord();
$latestArray = null;
if ($latest) {
$latestArray = [
'status' => $latest->status,
'backup_type' => $latest->backup_type,
'description' => $latest->description,
'backup_start' => $latest->backupstart,
'backup_end' => $latest->backupend,
'total_size' => (int) $latest->total_size,
'destination' => $latest->remote_filename ? 'remote' : 'local',
'profile' => $latest->profile_title,
'origin' => $latest->origin,
'files_count' => (int) $latest->files_count,
'tables_count' => (int) $latest->tables_count,
];
}
// Totals
$query = $db->getQuery(true)
->select([
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('complete') . ' THEN 1 ELSE 0 END) AS success',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS failed',
])
->from($db->quoteName('#__mokosuitebackup_records'));
$allTime = $db->setQuery($query)->loadObject();
// Recent (last 7 days)
$query = $db->getQuery(true)
->select([
'COUNT(*) AS total',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('complete') . ' THEN 1 ELSE 0 END) AS success',
'SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS failed',
])
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('backupstart') . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)');
$recent = $db->setQuery($query)->loadObject();
// Success streak — count consecutive successes from latest backward
$query = $db->getQuery(true)
->select($db->quoteName('status'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'fail'])) . ')')
->order($db->quoteName('backupstart') . ' DESC')
->setLimit(50);
$statuses = $db->setQuery($query)->loadColumn();
$streak = 0;
foreach ($statuses as $s) {
if ($s === 'complete') {
$streak++;
} else {
break;
}
}
return [
'installed' => true,
'latest' => $latestArray,
'totals' => [
'all_time' => (int) ($allTime->total ?? 0),
'all_success' => (int) ($allTime->success ?? 0),
'all_failed' => (int) ($allTime->failed ?? 0),
'recent_total' => (int) ($recent->total ?? 0),
'recent_success' => (int) ($recent->success ?? 0),
'recent_failed' => (int) ($recent->failed ?? 0),
'success_streak' => $streak,
],
];
}
}
@@ -36,7 +36,7 @@ class BackupModel extends AdminModel
$data = $this->getItem();
}
return $data;
return is_array($data) ? (object) $data : $data;
}
public function getTable($name = 'Backup', $prefix = 'Administrator', $options = [])
@@ -61,6 +61,13 @@ class BackupsModel extends ListModel
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
}
// Filter by backup type
$backupType = $this->getState('filter.backup_type');
if (!empty($backupType)) {
$query->where($db->quoteName('a.backup_type') . ' = ' . $db->quote($backupType));
}
// Filter by search
$search = $this->getState('filter.search');
@@ -36,7 +36,7 @@ class ProfileModel extends AdminModel
$data = $this->getItem();
}
return $data;
return is_array($data) ? (object) $data : $data;
}
public function getTable($name = 'Profile', $prefix = 'Administrator', $options = [])
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class SnapshotModel extends BaseDatabaseModel
{
/**
* Get a single snapshot record.
*
* @param int $pk Primary key
*
* @return object|null
*/
public function getItem(int $pk = 0): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . (int) $pk);
$db->setQuery($query);
return $db->loadObject() ?: null;
}
}
@@ -0,0 +1,64 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Model\ListModel;
class SnapshotsModel extends ListModel
{
public function __construct($config = [])
{
if (empty($config['filter_fields'])) {
$config['filter_fields'] = [
'id', 'a.id',
'description', 'a.description',
'created', 'a.created',
'data_size', 'a.data_size',
];
}
parent::__construct($config);
}
protected function getListQuery()
{
$db = $this->getDatabase();
$query = $db->getQuery(true);
$query->select('a.*')
->from($db->quoteName('#__mokosuitebackup_snapshots', 'a'));
// Search filter
$search = $this->getState('filter.search');
if (!empty($search)) {
$search = $db->quote('%' . $db->escape($search, true) . '%');
$query->where($db->quoteName('a.description') . ' LIKE ' . $search);
}
// Ordering
$orderCol = $this->state->get('list.ordering', 'a.created');
$orderDirn = $this->state->get('list.direction', 'DESC');
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDirn));
return $query;
}
protected function populateState($ordering = 'a.created', $direction = 'DESC')
{
$search = $this->getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string');
$this->setState('filter.search', $search);
parent::populateState($ordering, $direction);
}
}
@@ -39,11 +39,22 @@ class BackupTable extends Table
public function delete($pk = null): bool
{
// Delete the archive file if it exists
if (!empty($this->absolute_path) && is_file($this->absolute_path)) {
@unlink($this->absolute_path);
$archivePath = $this->absolute_path;
// Delete DB record first — if this fails, the file is preserved
$result = parent::delete($pk);
if ($result && !empty($archivePath) && is_file($archivePath)) {
@unlink($archivePath);
// Also remove the log file if it exists alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
if (is_file($logPath)) {
@unlink($logPath);
}
}
return parent::delete($pk);
return $result;
}
}
@@ -0,0 +1,55 @@
<?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\View\Snapshots;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
protected $items;
protected $pagination;
protected $state;
public function display($tpl = null): void
{
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->state = $this->get('State');
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
$user = Factory::getApplication()->getIdentity();
ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TITLE'), 'camera');
if ($user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
ToolbarHelper::custom('snapshots.create', 'plus', '', 'COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE', false);
}
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'snapshots.delete');
}
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
ToolbarHelper::preferences('com_mokosuitebackup');
}
}
}
@@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction'));
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
setTimeout(hideModal, 5000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -311,6 +318,34 @@ $listDirn = $this->escape($this->state->get('list.direction'));
// Expose for toolbar button
window.mokosuitebackupStart = startSteppedBackup;
// Intercept Restore toolbar button to show the modal
document.addEventListener('DOMContentLoaded', function() {
var restoreBtn = document.querySelector('[onclick*="backups.restore"], .button-upload');
if (restoreBtn) {
restoreBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Get selected record from checkboxes
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
if (checked.length === 0) {
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', true); ?>');
return false;
}
document.getElementById('mb-restore-record-id').value = checked[0].value;
document.getElementById('mb-restore-modal').style.display = 'block';
return false;
}, true);
}
});
// Close restore modal
document.addEventListener('click', function(e) {
if (e.target.classList.contains('mb-restore-close') || e.target.id === 'mb-restore-modal') {
document.getElementById('mb-restore-modal').style.display = 'none';
}
});
// View Log modal handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-view-log');
@@ -353,6 +388,61 @@ $listDirn = $this->escape($this->state->get('list.direction'));
})();
</script>
<!-- Restore Confirmation Modal -->
<div id="mb-restore-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;"><?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?></h4>
<button type="button" class="btn-close mb-restore-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.restore'); ?>" method="post" id="mb-restore-form">
<input type="hidden" name="id" id="mb-restore-record-id" value="">
<div style="padding:1.5rem;">
<div class="alert alert-danger">
<span class="icon-warning-circle" aria-hidden="true"></span>
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_CONFIRM'); ?></strong>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="restore_files" value="1" id="mb-restore-files" checked>
<label class="form-check-label" for="mb-restore-files">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_FILES'); ?>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="restore_db" value="1" id="mb-restore-db" checked>
<label class="form-check-label" for="mb-restore-db">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_DATABASE'); ?>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="preserve_config" value="1" id="mb-restore-config" checked>
<label class="form-check-label" for="mb-restore-config">
<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG'); ?>
<small class="text-muted d-block"><?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PRESERVE_CONFIG_DESC'); ?></small>
</label>
</div>
</div>
<div class="mb-3">
<label for="mb-restore-password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD'); ?></label>
<input type="password" class="form-control" id="mb-restore-password" name="encryption_password"
placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_RESTORE_PASSWORD_PLACEHOLDER'); ?>" autocomplete="off">
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-restore-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<!-- Log Viewer Modal -->
<div id="mb-log-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); display:flex; flex-direction:column; max-height:80vh;">
@@ -118,8 +118,8 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</div>
<div class="card-body">
<?php if (!empty($this->profiles)) : ?>
<div class="d-flex align-items-center gap-3 mb-3">
<select id="mb-profile-select" class="form-select" style="max-width:250px;">
<div class="mb-3">
<select id="mb-profile-select" class="form-select mb-2">
<?php foreach ($this->profiles as $profile) : ?>
<option value="<?php echo (int) $profile->id; ?>">
<?php echo $this->escape($profile->title); ?>
@@ -127,7 +127,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
</option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-primary" onclick="window.mokosuitebackupStart()">
<button type="button" class="btn btn-primary w-100" onclick="window.mokosuitebackupStart()">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW'); ?>
</button>
@@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
setTimeout(hideModal, 5000);
return;
}
// Show preflight warnings if any
if (initResult.warnings && initResult.warnings.length > 0) {
var warningEl = document.getElementById('mb-phase');
warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; ');
warningEl.style.color = '#856404';
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
@@ -0,0 +1,325 @@
<?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
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
HTMLHelper::_('behavior.multiselect');
$user = Factory::getApplication()->getIdentity();
$canManage = $user->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup');
$listOrder = $this->escape($this->state->get('list.ordering'));
$listDirn = $this->escape($this->state->get('list.direction'));
?>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" method="post" name="adminForm" id="adminForm">
<div class="row">
<div class="col-md-12">
<div id="j-main-container" class="j-main-container">
<?php if (empty($this->items)) : ?>
<div class="alert alert-info">
<span class="icon-info-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_NONE'); ?>
</div>
<?php else : ?>
<table class="table" id="snapshotList">
<caption class="visually-hidden"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOTS_TABLE_CAPTION'); ?></caption>
<thead>
<tr>
<td class="w-1 text-center">
<?php echo HTMLHelper::_('grid.checkall'); ?>
</td>
<th scope="col">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', 'a.description', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-15">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CONTENT_TYPES'); ?>
</th>
<th scope="col" class="w-10 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
</th>
<th scope="col" class="w-10 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
</th>
<th scope="col" class="w-10 text-center">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_SIZE', 'a.data_size', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_DATE', 'a.created', $listDirn, $listOrder); ?>
</th>
<th scope="col" class="w-10">
<?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>
</tr>
</thead>
<tbody>
<?php foreach ($this->items as $i => $item) : ?>
<?php $types = json_decode($item->content_types, true) ?: []; ?>
<tr>
<td class="text-center">
<?php echo HTMLHelper::_('grid.id', $i, $item->id); ?>
</td>
<td>
<?php echo $this->escape($item->description); ?>
<?php if ($item->status === 'fail') : ?>
<span class="badge bg-danger">Failed</span>
<?php endif; ?>
</td>
<td>
<?php foreach ($types as $type) : ?>
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
<?php endforeach; ?>
</td>
<td class="text-center"><?php echo (int) $item->articles_count; ?></td>
<td class="text-center"><?php echo (int) $item->categories_count; ?></td>
<td class="text-center"><?php echo (int) $item->modules_count; ?></td>
<td>
<?php echo $item->data_size > 0 ? HTMLHelper::_('number.bytes', $item->data_size) : '—'; ?>
</td>
<td>
<?php echo HTMLHelper::_('date', $item->created, Text::_('DATE_FORMAT_LC4')); ?>
</td>
<td>
<?php if ($item->status === 'complete' && $canManage) : ?>
<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); ?>"
data-desc="<?php echo $this->escape($item->description); ?>"
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>">
<span class="icon-upload"></span>
</button>
<?php endif; ?>
</td>
<td><?php echo (int) $item->id; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php echo $this->pagination->getListFooter(); ?>
<?php endif; ?>
<input type="hidden" name="task" value="">
<input type="hidden" name="boxchecked" value="0">
<?php echo HTMLHelper::_('form.token'); ?>
</div>
</div>
</div>
</form>
<!-- Create Snapshot Modal -->
<div id="mb-snapshot-create-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;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?></h4>
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.create'); ?>" method="post" id="mb-snapshot-create-form">
<div style="padding:1.5rem;">
<div class="mb-3">
<label for="mb-snap-desc" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION'); ?></label>
<input type="text" class="form-control" id="mb-snap-desc" name="description" placeholder="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_DESC_PLACEHOLDER'); ?>">
</div>
<div class="mb-3">
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_SELECT_TYPES'); ?></label>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="content_types[]" value="articles" id="mb-snap-articles" checked>
<label class="form-check-label" for="mb-snap-articles">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES'); ?>
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES_DESC'); ?>)</small>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="content_types[]" value="categories" id="mb-snap-categories" checked>
<label class="form-check-label" for="mb-snap-categories">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES'); ?>
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES_DESC'); ?>)</small>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="content_types[]" value="modules" id="mb-snap-modules" checked>
<label class="form-check-label" for="mb-snap-modules">
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES'); ?>
<small class="text-muted">(<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES_DESC'); ?>)</small>
</label>
</div>
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-primary">
<span class="icon-camera" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CREATE'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<!-- Restore Snapshot Modal -->
<div id="mb-snapshot-restore-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;"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?></h4>
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
</div>
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restore'); ?>" method="post" id="mb-snapshot-restore-form">
<input type="hidden" name="id" id="mb-restore-id" value="">
<div style="padding:1.5rem;">
<p id="mb-restore-desc" class="fw-bold"></p>
<div class="mb-3">
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_MODE'); ?></label>
<div class="form-check">
<input class="form-check-input" type="radio" name="restore_mode" value="replace" id="mb-mode-replace" checked>
<label class="form-check-label" for="mb-mode-replace">
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE'); ?></strong>
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_REPLACE_DESC'); ?></small>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" name="restore_mode" value="merge" id="mb-mode-merge">
<label class="form-check-label" for="mb-mode-merge">
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE'); ?></strong>
<br><small class="text-muted"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODE_MERGE_DESC'); ?></small>
</label>
</div>
</div>
<div class="mb-3" id="mb-restore-types-container">
<label class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES'); ?></label>
<!-- Populated by JS from data-types -->
</div>
<div class="alert alert-warning mb-0" id="mb-replace-warning">
<span class="icon-warning-circle" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_REPLACE_WARNING'); ?>
</div>
</div>
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
<button type="submit" class="btn btn-danger">
<span class="icon-upload" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE'); ?>
</button>
</div>
<?php echo HTMLHelper::_('form.token'); ?>
</form>
</div>
</div>
<script>
(function() {
// Create Snapshot — intercept toolbar button
document.addEventListener('DOMContentLoaded', function() {
var createBtn = document.querySelector('[onclick*="snapshots.create"], .button-plus');
if (createBtn) {
createBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
document.getElementById('mb-snapshot-create-modal').style.display = 'block';
return false;
}, true);
}
});
// Restore Snapshot — click handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-snapshot-restore');
if (!btn) return;
e.preventDefault();
var id = btn.getAttribute('data-id');
var types = JSON.parse(btn.getAttribute('data-types') || '[]');
var desc = btn.getAttribute('data-desc');
document.getElementById('mb-restore-id').value = id;
document.getElementById('mb-restore-desc').textContent = 'Restoring: ' + desc;
// Build type checkboxes using safe DOM methods
var container = document.getElementById('mb-restore-types-container');
while (container.firstChild) container.removeChild(container.firstChild);
var heading = document.createElement('label');
heading.className = 'form-label fw-bold';
heading.textContent = <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_TYPES')); ?>;
container.appendChild(heading);
var typeLabels = {
articles: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_ARTICLES')); ?>,
categories: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_CATEGORIES')); ?>,
modules: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_MODULES')); ?>
};
var allowedTypes = ['articles', 'categories', 'modules'];
types.forEach(function(type) {
if (allowedTypes.indexOf(type) === -1) return;
var div = document.createElement('div');
div.className = 'form-check';
var input = document.createElement('input');
input.className = 'form-check-input';
input.type = 'checkbox';
input.name = 'restore_types[]';
input.value = type;
input.id = 'mb-rtype-' + type;
input.checked = true;
var label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', 'mb-rtype-' + type);
label.textContent = typeLabels[type] || type;
div.appendChild(input);
div.appendChild(label);
container.appendChild(div);
});
// Show/hide replace warning based on mode
toggleReplaceWarning();
document.getElementById('mb-snapshot-restore-modal').style.display = 'block';
});
// Toggle warning when mode changes
document.addEventListener('change', function(e) {
if (e.target.name === 'restore_mode') {
toggleReplaceWarning();
}
});
function toggleReplaceWarning() {
var isReplace = document.getElementById('mb-mode-replace').checked;
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
}
// Close modals
document.addEventListener('click', function(e) {
if (e.target.classList.contains('mb-modal-close') ||
e.target.id === 'mb-snapshot-create-modal' ||
e.target.id === 'mb-snapshot-restore-modal') {
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
}
});
})();
</script>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.28.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.28.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -86,7 +86,7 @@ class RestoreCommand extends AbstractCommand
}
$engine = new RestoreEngine();
$result = $engine->restore($record->absolute_path, $record->backup_type);
$result = $engine->restore($recordId);
if ($result['success']) {
$io->success($result['message']);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.28.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.22.06-dev</version>
<version>01.28.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.22.06-dev</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -59,11 +59,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
// Reject if disabled or no secret configured
if (!$enabled || $configSecret === '') {
$this->sendJsonResponse(false, 'Web cron is not enabled', 403);
return;
}
// Validate secret (timing-safe comparison)
if (!hash_equals($configSecret, $secret)) {
$this->sendJsonResponse(false, 'Invalid secret', 403);
return;
}
// IP whitelist check (if configured)
@@ -73,6 +77,8 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
if (!in_array($clientIp, $allowedIps, true)) {
$this->sendJsonResponse(false, 'IP not allowed', 403);
return;
}
}
@@ -138,6 +144,15 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
* A profile value of 0 means "use the global default".
*/
private function cleanupOldBackups(): void
{
try {
$this->doCleanup();
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: cleanupOldBackups() failed: ' . $e->getMessage());
}
}
private function doCleanup(): void
{
$db = Factory::getDbo();
$globalMaxAge = (int) ComponentHelper::getParams('com_mokosuitebackup')->get('max_age_days', 30);
@@ -219,10 +234,11 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
{
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
if (!@unlink($record->absolute_path)) {
return; // Don't delete DB record if file can't be removed
error_log('MokoSuiteBackup: Could not delete backup file (id=' . $record->id . '): ' . $record->absolute_path);
return;
}
// Also remove the log file if it exists alongside the archive
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path);
if (is_file($logPath)) {
@@ -230,12 +246,16 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
}
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
try {
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: Could not delete backup record ' . $record->id . ': ' . $e->getMessage());
}
}
/**
@@ -291,7 +311,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
'warning'
);
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: ' . $description . ' failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup: ' . $description . ' failed — ' . $e->getMessage(),
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.22.06-dev</version>
<version>01.28.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.22.06-dev</version>
<version>01.28.01</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.22.06-dev</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+62
View File
@@ -150,6 +150,9 @@ class Pkg_MokoSuiteBackupInstallerScript
// Create default backup directory in site root
$this->createBackupDirectory();
// Generate a random webcron secret word
$this->generateWebcronSecret();
// Create default scheduled task for backup automation
$this->createDefaultScheduledTask();
}
@@ -185,6 +188,58 @@ class Pkg_MokoSuiteBackupInstallerScript
}
}
/**
* Generate a cryptographically random webcron secret word on fresh install.
*/
private function generateWebcronSecret(): void
{
try {
$db = Factory::getDbo();
// Load current component params
$query = $db->getQuery(true)
->select($db->quoteName('params'))
->from($db->quoteName('#__extensions'))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'))
->setLimit(1);
$db->setQuery($query);
$rawParams = $db->loadResult();
$params = json_decode($rawParams ?: '{}', true) ?: [];
// Only generate if not already set
if (!empty($params['webcron_secret'])) {
return;
}
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$secret = '';
$bytes = random_bytes(32);
for ($i = 0; $i < 32; $i++) {
$secret .= $chars[ord($bytes[$i]) % strlen($chars)];
}
$params['webcron_secret'] = $secret;
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('params') . ' = ' . $db->quote(json_encode($params)))
->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuitebackup'))
->where($db->quoteName('type') . ' = ' . $db->quote('component'));
$db->setQuery($query);
$db->execute();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: generateWebcronSecret() failed: ' . $e->getMessage());
Factory::getApplication()->enqueueMessage(
'MokoSuiteBackup could not generate a random webcron secret. '
. 'Please set one manually in the component options to secure the webcron endpoint.',
'warning'
);
}
}
private function enableBundledPlugins(): void
{
$folders = ['system', 'quickicon', 'task', 'webservices', 'console', 'content', 'actionlog'];
@@ -388,6 +443,12 @@ class Pkg_MokoSuiteBackupInstallerScript
'img' => 'class:database',
'menu_icon' => 'icon-database',
],
[
'link' => 'index.php?option=com_mokosuitebackup&view=snapshots',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_SNAPSHOTS',
'img' => 'class:camera',
'menu_icon' => 'icon-camera',
],
[
'link' => 'index.php?option=com_mokosuitebackup&view=profiles',
'title' => 'COM_MOKOJOOMBACKUP_SUBMENU_PROFILES',
@@ -522,6 +583,7 @@ class Pkg_MokoSuiteBackupInstallerScript
$iconMap = [
'view=dashboard' => 'class:home',
'view=backups' => 'class:database',
'view=snapshots' => 'class:camera',
'view=profiles' => 'class:cog',
];
@@ -1,98 +0,0 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_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
*
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
*
* Akeeba-compatible routes:
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
* GET /api/index.php/v1/mokosuitebackup/backups — List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
*/
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Router\Route;
final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onBeforeApiRoute' => 'onBeforeApiRoute',
];
}
public function onBeforeApiRoute(Event $event): void
{
/** @var ApiRouter $router */
[$router] = array_values($event->getArguments());
$defaults = [
'component' => 'com_mokosuitebackup',
'public' => false,
];
// Standard CRUD for backup records
$router->createCRUDRoutes('v1/mokosuitebackup/backups', 'backups', $defaults);
// Start a backup (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/backup',
'backups.backup',
[],
$defaults
)
);
// Delete a backup (DELETE)
$router->addRoute(
new Route(
['DELETE'],
'v1/mokosuitebackup/backup/:id',
'backups.delete',
['id' => '(\d+)'],
$defaults
)
);
// Download a backup archive (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/backup/:id/download',
'backups.download',
['id' => '(\d+)'],
$defaults
)
);
// List backup profiles (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/profiles',
'backups.profiles',
[],
$defaults
)
);
}
}