42 Commits

Author SHA1 Message Date
Jonathan Miller 75e72c248a feat: tar.gz archives, table checkbox excludes, user group notifications (#32, #33, #34)
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
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 / Release configuration (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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Archive formats (#32):
- ArchiverInterface abstraction with ZipArchiver and TarGzArchiver
- BackupEngine uses archiver factory based on profile archive_format
- tar.gz uses PharData (bundled with PHP, no extra extensions)
- RestoreEngine detects and extracts tar.gz via PharData
- AES-256 encryption skipped for non-ZIP formats with log warning

Exclude fields (#33):
- ExcludeListField: dynamic table with add/remove rows for dirs and files
- DatabaseTablesField: auto-populated checkbox list of all site tables
- Replaces textarea-based exclusion fields in profile form

User group notifications (#34):
- usergrouplist field added to profile notifications fieldset
- NotificationSender resolves group members to emails at send time
- Combined with manual email addresses, deduplicated
- SQL migration adds notify_user_groups column

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:40:07 -05:00
Jonathan Miller cf50551595 feat: folder picker field and default directory dashboard warning
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) 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
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 / Release configuration (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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
- Custom FolderPickerField with AJAX server-side directory browser
  (Browse button opens collapsible tree, click to navigate, double-click
  to select). Uses safe DOM methods instead of innerHTML.
- AjaxController::browseDir() endpoint lists subdirectories
- Dashboard shows warning when any profile uses the default backup
  directory inside the web root
- Profile form and config.xml use FolderPicker for backup_dir field

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:30:28 -05:00
Jonathan Miller 368461577e fix: CLI exit codes and SQL schema defaults
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
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 / Release configuration (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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
- Console commands: replace self::SUCCESS/FAILURE with 0/1 (Joomla's
  AbstractCommand doesn't define these Symfony constants)
- SQL: make manifest and log columns DEFAULT NULL to prevent
  "doesn't have a default value" on INSERT

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 20:15:52 -05:00
Jonathan Miller fea1800e06 fix: console plugin namespace and quickicon translation
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
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 / Release configuration (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
- Console: use Joomla\Application\ApplicationEvents (was wrong
  namespace Joomla\Application\Event\ApplicationEvents causing
  ClassNotFoundError on CLI)
- Quickicon: translate text key with Text::_() so language strings
  render instead of showing raw keys

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:57:36 -05:00
Jonathan Miller d19274d88b chore: remove deprecated update-server.yml workflow [skip ci]
Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 19:06:06 -05:00
jmiller 26635f933c chore: add dlid and blockChildUninstall to package manifest [skip ci] 2026-06-04 22:02:33 +00:00
gitea-actions[bot] 8d80c218da chore(version): auto-bump 01.01.07-dev [skip ci] 2026-06-04 21:23:01 +00:00
Jonathan Miller 807da034c9 fix: add config.xml for component Options
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) 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
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 / Release configuration (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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
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
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Update Server / Update Server (push) Successful in 11s
The Options toolbar button was crashing with "Form::loadForm could not
load file" because config.xml did not exist. Added config.xml with
global settings: default backup dir, default profile, update notice
toggle, cleanup defaults, notification defaults, and permissions.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 16:22:15 -05:00
gitea-actions[bot] e4d704dd84 chore(version): auto-bump 01.01.06-dev [skip ci] 2026-06-04 19:13:45 +00:00
Jonathan Miller a805351dd1 fix: move postinstall string to package sys.ini
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) 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
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 / Release configuration (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
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 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 3s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Update Server / Update Server (push) Successful in 12s
COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE was showing as a raw key
after install because the component language file isn't loaded
during package installation. Moved to PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE
in pkg_mokobackup.sys.ini where the installer can resolve it.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 14:13:32 -05:00
Jonathan Miller c5d4445bc1 fix: rewrite Makefile for CI-based releases
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
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 / Release configuration (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
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Remove local build/package targets — all packaging is handled by
the pre-release and auto-release CI workflows. The release target
now dispatches the pre-release workflow via Gitea API. Added
release-rc target and validate-xml check.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 13:40:29 -05:00
Jonathan Miller 5460c7b211 chore: consolidate changelog entries to minor versions only
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) Blocked by required conditions
Generic: Repo Health / Report Issues (push) 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
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 / Release configuration (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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Collapsed all patch-level entries (01.00.00, 01.01.01–01.01.04) into
two minor version sections: 01.00 and 01.01. Deduplicated and grouped
entries by Added/Changed/Fixed within each.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 12:56:34 -05:00
gitea-actions[bot] 5ab6296ad5 chore(version): auto-bump 01.01.05-dev [skip ci] 2026-06-04 16:29:55 +00:00
Jonathan Miller c1521cb235 feat: add dashboard view and console, content, actionlog plugins (#24, #25, #26, #27)
Generic: Repo Health / Release configuration (push) Blocked by required conditions
Generic: Repo Health / Scripts governance (push) Blocked by required conditions
Generic: Repo Health / Repository health (push) 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
Generic: Repo Health / Report Issues (push) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (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 / Release configuration (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
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Update Server / Update Server (push) Successful in 10s
Dashboard view becomes the default landing page with status cards,
quick actions (backup now w/ profile selector), and system health
checks. Three new plugins round out the package:

- plg_console_mokobackup: CLI commands (run, list, profiles, restore, cleanup)
- plg_content_mokobackup: auto-backup before extension install/update
- plg_actionlog_mokobackup: logs backup and profile actions to User Action Logs

BackupEngine now dispatches onMokoBackupAfterRun for plugin listeners.
Package manifest and install script updated to include and auto-enable
the new plugins.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 11:26:45 -05:00
jmiller 25e06fd08c chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:56:54 +00:00
jmiller 61abf72437 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:39:23 +00:00
jmiller 8c5ed1ed76 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:30:34 +00:00
jmiller e9d7889417 chore: remove updates.xml [skip ci] 2026-06-04 15:27:07 +00:00
jmiller 4c15f12426 chore: sync .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-06-04 15:14:33 +00:00
gitea-actions[bot] 8d082de47f chore: update development channel 01.01.04-dev [skip ci] 2026-06-04 14:44:23 +00:00
gitea-actions[bot] 41c1bb5d68 chore(version): pre-release bump to 01.01.04-dev [skip ci] 2026-06-04 14:44:22 +00:00
jmiller 4f14009003 feat(update): migrate update server URL to Gitea Pages [skip ci] 2026-06-04 14:34:10 +00:00
jmiller 8e51abee54 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-06-04 14:21:21 +00:00
Jonathan Miller f884314e28 fix: consolidate admin files into single files block
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 7s
Universal: PR Check / Validate PR (pull_request) Successful in 7s
Update Server / Update Server (push) Failing after 7s
Generic: Repo Health / Release configuration (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
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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
Joomla core components use a single <files folder="admin"> block with
<folder> entries for all subdirectories. Our manifest had multiple
separate <files folder="..."> blocks which may not be fully processed
by the component installer. Consolidate to match the Joomla standard
pattern using <files folder="."> with folder entries.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 09:20:54 -05:00
gitea-actions[bot] e495c786fb chore: update development channel 01.01.04-dev [skip ci] 2026-06-04 14:02:49 +00:00
gitea-actions[bot] c3cbad1a00 chore: update development channel 01.01.04-dev [skip ci] 2026-06-04 13:57:48 +00:00
gitea-actions[bot] a34b715cff chore(version): auto-bump 01.01.04-dev [skip ci] 2026-06-04 13:57:46 +00:00
Jonathan Miller 3ded91608a fix: remove orphaned scriptfile from component manifest
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 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Update Server / Update Server (push) Successful in 11s
Generic: Repo Health / Release configuration (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
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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
The component manifest declared <scriptfile>script.php</scriptfile> but
no script.php exists in the component sub-package. The install script
belongs at the package level (pkg_mokobackup.xml) only. This caused
Joomla to abort component installation before copying files or running
SQL, producing the "SQL File not found" error.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 08:57:30 -05:00
gitea-actions[bot] 119379bb16 chore: update release-candidate channel 01.01.03-dev [skip ci] 2026-06-04 13:17:50 +00:00
gitea-actions[bot] 8eab341c1e chore(version): pre-release bump to 01.01.03-dev [skip ci] 2026-06-04 13:17:49 +00:00
gitea-actions[bot] 70df427cfe chore: update development channel 01.01.03-dev [skip ci] 2026-06-04 13:16:02 +00:00
gitea-actions[bot] 2aee667d00 chore(version): auto-bump 01.01.03-dev [skip ci] 2026-06-04 13:16:01 +00:00
Jonathan Miller 6bab1ad5fa fix: add SQL update migration and error handling for PR review
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 3s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: Auto Version Bump / Version Bump (push) Failing after 3s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Update Server / Update Server (push) Successful in 10s
Generic: Repo Health / Release configuration (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
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
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Release configuration (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
- Add sql/updates/mysql/01.01.01.sql to rename include_kickstart
  column to include_mokorestore for existing installations
- Wrap checkUpdateSite() in try/catch to prevent dashboard crash
- Use COM_MOKOBACKUP_UPDATE_SITE_MISSING when no update site found

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 08:15:48 -05:00
gitea-actions[bot] 5ee4f7a578 chore: update development channel 01.01.02-dev [skip ci] 2026-06-04 13:02:06 +00:00
gitea-actions[bot] 44e309c57f chore(version): auto-bump 01.01.02-dev [skip ci] 2026-06-04 13:02:04 +00:00
Jonathan Miller 6ed0eee4a1 feat: add update site notice on dashboard and post-install
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 4s
Update Server / Update Server (push) Successful in 12s
Generic: Repo Health / Release configuration (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
Link directly to the Joomla Update Sites record for pkg_mokobackup
on the Backups dashboard and after install/update, so users can
configure their download key without a license warning popup.

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 08:01:52 -05:00
gitea-actions[bot] 3d88281e72 chore: update development channel 01.01.01-dev [skip ci] 2026-06-04 12:41:02 +00:00
gitea-actions[bot] 1f225689ba chore(version): auto-bump 01.01.01-dev [skip ci] 2026-06-04 12:40:59 +00:00
Jonathan Miller e5ca71f2c5 chore: sync workflows from moko-platform + fix SQL install path
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 10s
Update Server / Update Server (push) Successful in 21s
Generic: Repo Health / Release configuration (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
- Sync all universal workflows from moko-platform v09.23.00
- Add pre-release.yml for dev/alpha/beta/rc builds
- Add auto-bump.yml, branch-cleanup.yml, issue-branch.yml, update-server.yml
- Fix mokobackup.xml: include install/uninstall SQL files in <files> section

Authored-by: Moko Consulting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 07:40:04 -05:00
Jonathan Miller 610f875ad9 refactor: rename Kickstart to MokoRestore
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (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
Replace all Akeeba Kickstart branding with MokoRestore:
- Rename Kickstart.php to MokoRestore.php
- Rename class Kickstart to MokoRestore
- Update DB column: include_kickstart → include_mokorestore
- Update form field and language string keys
- Update all variable names, log messages, and comments
- Update CHANGELOG references

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 04:37:27 -05:00
jmiller 507b2c9448 chore: sync .mokogitea/workflows/repo-health.yml from moko-platform [skip ci] 2026-06-03 03:11:26 +00:00
Jonathan Miller aa36a01a7f chore: bump version to 01.01.00-dev for next development cycle
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 21:58:43 -05:00
80 changed files with 4915 additions and 1778 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoJoomBackup</display-name> <display-name>Package - MokoJoomBackup</display-name>
<org>MokoConsulting</org> <org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description> <description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.00.00</version> <version>01.01.07-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license> <license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity> </identity>
<governance> <governance>
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform 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"
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"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+285 -285
View File
@@ -1,285 +1,285 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release # INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/universal/auto-release.yml.template # PATH: /templates/workflows/universal/auto-release.yml.template
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +========================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +========================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
# | Platform-specific: | # | Platform-specific: |
# | joomla: XML manifest, updates.xml, type-prefixed packages | # | joomla: XML manifest, updates.xml, type-prefixed packages |
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +========================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
on: on:
pull_request: pull_request:
types: [opened, closed] types: [opened, closed]
branches: branches:
- main - main
workflow_dispatch: workflow_dispatch:
inputs: inputs:
action: action:
description: 'Action to perform' description: 'Action to perform'
required: false required: false
type: choice type: choice
default: release default: release
options: options:
- release - release
- promote-rc - promote-rc
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }}
GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }}
permissions: permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
if: >- if: >-
(github.event.action == 'opened' && github.event.pull_request.merged != true) || (github.event.action == 'opened' && github.event.pull_request.merged != true) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc')
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1 fetch-depth: 1
- name: Setup moko-platform tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
run: | run: |
if ! command -v composer &> /dev/null; then if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi fi
# Always fetch latest CLI tools — never use stale cache from previous runs # Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api /tmp/moko-platform-api
cd /tmp/moko-platform-api cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet composer install --no-dev --no-interaction --quiet
- name: Rename branch to rc - name: Rename branch to rc
run: | run: |
php /tmp/moko-platform-api/cli/branch_rename.php \ php /tmp/moko-platform-api/cli/branch_rename.php \
--from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \
--pr "${{ github.event.pull_request.number }}" --pr "${{ github.event.pull_request.number }}"
- name: Checkout rc and configure git - name: Checkout rc and configure git
run: | run: |
git fetch origin rc git fetch origin rc
git checkout rc git checkout rc
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]" git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Publish RC release - name: Publish RC release
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability rc --bump minor --branch rc \ --path . --stability rc --bump minor --branch rc \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream --skip-update-stream
- name: Summary - name: Summary
if: always() if: always()
run: | run: |
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ────────────────────
release: release:
name: Build & Release Pipeline name: Build & Release Pipeline
runs-on: release runs-on: release
if: >- if: >-
github.event.pull_request.merged == true || github.event.pull_request.merged == true ||
(github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with: with:
token: ${{ secrets.MOKOGITEA_TOKEN }} token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 0 fetch-depth: 0
- name: Configure git for bot pushes - name: Configure git for bot pushes
run: | run: |
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]" git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
- name: Check for merge conflict markers - name: Check for merge conflict markers
run: | run: |
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
if [ -n "$CONFLICTS" ]; then if [ -n "$CONFLICTS" ]; then
echo "::error::Merge conflict markers found — aborting release" echo "::error::Merge conflict markers found — aborting release"
echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
exit 1 exit 1
fi fi
echo "No conflict markers found" echo "No conflict markers found"
- name: Setup moko-platform tools - name: Setup moko-platform tools
env: env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}'
run: | run: |
# Ensure PHP + Composer are available # Ensure PHP + Composer are available
if ! command -v composer &> /dev/null; then if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi fi
# Always fetch latest CLI tools — never use stale cache from previous runs # Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \ git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \
/tmp/moko-platform-api /tmp/moko-platform-api
cd /tmp/moko-platform-api cd /tmp/moko-platform-api
composer install --no-dev --no-interaction --quiet composer install --no-dev --no-interaction --quiet
- name: "Publish stable release" - name: "Publish stable release"
run: | run: |
php /tmp/moko-platform-api/cli/release_publish.php \ php /tmp/moko-platform-api/cli/release_publish.php \
--path . --stability stable --bump minor --branch main \ --path . --stability stable --bump minor --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" \
--skip-update-stream --skip-update-stream
# -- STEP 9: Mirror to GitHub (stable only) -------------------------------- # -- STEP 9: Mirror to GitHub (stable only) --------------------------------
- name: "Step 9: Mirror release to GitHub" - name: "Step 9: Mirror release to GitHub"
if: >- if: >-
steps.version.outputs.skip != 'true' && steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != '' secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true continue-on-error: true
run: | run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}" RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/release_mirror.php \ php /tmp/moko-platform-api/cli/release_mirror.php \
--version "$VERSION" --tag "$RELEASE_TAG" \ --version "$VERSION" --tag "$RELEASE_TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \
--branch main 2>&1 || true --branch main 2>&1 || true
echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY
# -- STEP 10: Sync main branch to GitHub mirror ---------------------------- # -- STEP 10: Sync main branch to GitHub mirror ----------------------------
- name: "Step 10: Push main to GitHub mirror" - name: "Step 10: Push main to GitHub mirror"
if: >- if: >-
steps.version.outputs.skip != 'true' && steps.version.outputs.skip != 'true' &&
secrets.GH_MIRROR_TOKEN != '' secrets.GH_MIRROR_TOKEN != ''
continue-on-error: true continue-on-error: true
run: | run: |
GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}"
GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1)
GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2)
git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \
git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git"
git fetch origin main --depth=1 git fetch origin main --depth=1
git push github origin/main:refs/heads/main --force 2>/dev/null \ git push github origin/main:refs/heads/main --force 2>/dev/null \
&& echo "main branch pushed to GitHub mirror" \ && echo "main branch pushed to GitHub mirror" \
|| echo "WARNING: GitHub mirror push failed" || echo "WARNING: GitHub mirror push failed"
- name: "Step 11: Delete rc branch and recreate dev from main" - name: "Step 11: Delete rc branch and recreate dev from main"
if: steps.version.outputs.skip != 'true' if: steps.version.outputs.skip != 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Delete rc branch (ephemeral — created by promote-rc) # Delete rc branch (ephemeral — created by promote-rc)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/rc" 2>/dev/null \ "${API_BASE}/branches/rc" 2>/dev/null \
&& echo "Deleted rc branch" || echo "rc branch not found" && echo "Deleted rc branch" || echo "rc branch not found"
# Delete dev branch # Delete dev branch
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
"${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch"
# Recreate dev from main (now includes version bump + changelog promotion) # Recreate dev from main (now includes version bump + changelog promotion)
curl -sf -X POST -H "Authorization: token ${TOKEN}" \ curl -sf -X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${API_BASE}/branches" \ "${API_BASE}/branches" \
-d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main"
echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY
- name: "Step 12: Create version branch from main" - name: "Step 12: Create version branch from main"
if: steps.version.outputs.skip != 'true' if: steps.version.outputs.skip != 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
BRANCH_NAME="version/${VERSION}" BRANCH_NAME="version/${VERSION}"
MAIN_SHA=$(git rev-parse HEAD) MAIN_SHA=$(git rev-parse HEAD)
# Delete old version branch if it exists (same version re-release) # Delete old version branch if it exists (same version re-release)
curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}"
# Create version/XX.YY.ZZ from main # Create version/XX.YY.ZZ from main
curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed"
echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY
# -- Dolibarr post-release: Reset dev version ----------------------------- # -- Dolibarr post-release: Reset dev version -----------------------------
- name: "Post-release: Reset dev version" - name: "Post-release: Reset dev version"
if: steps.version.outputs.skip != 'true' if: steps.version.outputs.skip != 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php /tmp/moko-platform-api/cli/version_reset_dev.php \ php /tmp/moko-platform-api/cli/version_reset_dev.php \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \
--branch dev --path . 2>&1 || true --branch dev --path . 2>&1 || true
# -- Summary -------------------------------------------------------------- # -- Summary --------------------------------------------------------------
- name: Pipeline Summary - name: Pipeline Summary
if: always() if: always()
run: | run: |
VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
PLATFORM="${{ steps.platform.outputs.platform }}" PLATFORM="${{ steps.platform.outputs.platform }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else else
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi fi
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 09.23.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Maintenance # INGROUP: moko-platform.Maintenance
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/cleanup.yml # PATH: /.gitea/workflows/cleanup.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs
name: "Universal: Repository Cleanup" name: "Universal: Repository Cleanup"
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform
# PATH: /templates/workflows/gitleaks.yml.template # PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
# #
# +========================================================================+ # +========================================================================+
+73
View File
@@ -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: moko-platform.Automation
# VERSION: 01.01.07
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
on:
issues:
types: [opened]
permissions:
contents: write
issues: write
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
create-branch:
name: Create feature branch
runs-on: ubuntu-latest
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
# Build slug from title: lowercase, replace non-alnum with dash, trim
SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40)
BRANCH="feature/${ISSUE_NUM}-${SLUG}"
# Check dev branch exists
DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${API}/branches/dev" 2>/dev/null || echo "000")
if [ "${DEV_EXISTS}" != "200" ]; then
echo "No dev branch -- skipping"
exit 0
fi
# Create branch from dev
HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/branches" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000")
if [ "${HTTP}" = "201" ]; then
echo "Created branch: ${BRANCH}"
# Comment on issue with branch link
REPO_URL="${GITEA_URL}/${{ github.repository }}"
BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`"
curl -sf -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API}/issues/${ISSUE_NUM}/comments" \
-d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1
echo "Commented on issue #${ISSUE_NUM}"
else
echo "Failed to create branch (HTTP ${HTTP}) -- may already exist"
fi
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Notifications # INGROUP: moko-platform.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/notify.yml # PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Push notifications via ntfy on release success or workflow failure # BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications" name: "Universal: Notifications"
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 09.23.00
# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch
name: "Universal: Pre-Release"
on:
pull_request:
types: [closed]
branches:
- dev
workflow_dispatch:
inputs:
stability:
description: 'Pre-release channel'
required: true
type: choice
options:
- development
- alpha
- beta
- release-candidate
permissions:
contents: write
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:
build:
name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
- name: Setup moko-platform tools
env:
MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting
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
# Always fetch latest CLI tools — never use stale cache from previous runs
rm -rf /tmp/moko-platform-api
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/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"
- name: Detect platform
id: platform
run: |
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
STABILITY="${{ inputs.stability || 'development' }}"
case "$STABILITY" in
development) TAG="development" ;;
alpha) TAG="alpha" ;;
beta) TAG="beta" ;;
release-candidate) TAG="release-candidate" ;;
esac
# Set stability suffix, bump preserves it, fix consistency
php ${MOKO_CLI}/version_set_platform.php \
--path . --version "$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '00.00.01')" \
--branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true
php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
# Read final version (includes suffix, e.g. 01.02.15-dev)
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null)
[ -z "$VERSION" ] && VERSION="00.00.01"
# Commit version bump
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
git add -A
git diff --cached --quiet || {
git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]"
git push origin HEAD 2>&1
}
# Auto-detect element via manifest_element.php
php ${MOKO_CLI}/manifest_element.php \
--path . --version "$VERSION" --stability "$STABILITY" \
--repo "${GITEA_REPO}" --github-output
# Read back element outputs
EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2)
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
[ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
- name: Create release
id: release
run: |
TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_create.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --branch dev --prerelease
- name: Build package and upload
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
php ${MOKO_CLI}/release_package.php \
--path . --version "$VERSION" --tag "$TAG" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \
--repo "${GITEA_REPO}" --output /tmp || true
- name: Update updates.xml
if: steps.platform.outputs.platform == 'joomla'
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
if [ ! -f "updates.xml" ]; then
echo "No updates.xml -- skipping"
exit 0
fi
SHA_FLAG=""
[ -n "$SHA256" ] && SHA_FLAG="--sha ${SHA256}"
php ${MOKO_CLI}/updates_xml_build.php \
--path . --version "${VERSION}" --stability "${STABILITY}" \
--gitea-url "${GITEA_URL}" --org "${GITEA_ORG}" --repo "${GITEA_REPO}" \
${SHA_FLAG}
# Commit and push
if ! git diff --quiet updates.xml 2>/dev/null; then
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
git add updates.xml
git commit -m "chore: update ${STABILITY} channel ${VERSION} [skip ci]"
git push origin HEAD 2>&1 || echo "WARNING: push failed"
fi
- name: "Sync updates.xml to all branches"
if: steps.platform.outputs.platform == 'joomla'
run: |
CURRENT_BRANCH="${{ github.ref_name }}"
git config --local user.email "gitea-actions[bot]@mokoconsulting.tech"
git config --local user.name "gitea-actions[bot]"
for BRANCH in main dev; do
[ "$BRANCH" = "$CURRENT_BRANCH" ] && continue
echo "Syncing updates.xml -> ${BRANCH}"
git fetch origin "${BRANCH}" 2>/dev/null || continue
git checkout "origin/${BRANCH}" -- updates.xml 2>/dev/null || continue
git checkout "${CURRENT_BRANCH}" -- updates.xml
if ! git diff --quiet updates.xml 2>/dev/null; then
git add updates.xml
git commit -m "chore: sync updates.xml from ${CURRENT_BRANCH} [skip ci]"
git push origin HEAD:refs/heads/${BRANCH} 2>&1 || echo "WARNING: push to ${BRANCH} failed"
fi
git checkout "${CURRENT_BRANCH}" 2>/dev/null
done
- name: "Delete lesser pre-release channels (cascade)"
continue-on-error: true
run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
php ${MOKO_CLI}/release_cascade.php \
--stability "${{ steps.meta.outputs.stability }}" \
--token "${TOKEN}" \
--api-base "${API_BASE}"
- name: Summary
if: always()
run: |
VERSION="${{ steps.meta.outputs.version }}"
STABILITY="${{ steps.meta.outputs.stability }}"
ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
SHA256="${{ steps.package.outputs.sha256_zip }}"
echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
# INGROUP: moko-platform.Security # INGROUP: moko-platform.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/security-audit.yml # PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00 # VERSION: 09.23.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages # BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit" name: "Universal: Security Audit"
+29 -36
View File
@@ -2,62 +2,55 @@
## [Unreleased] ## [Unreleased]
## 01.00.00 — 2026-06-02 ## 01.01 — 2026-06-04
### Added
- Admin dashboard view as default landing page with status cards, quick actions, and system health checklist (#28)
- Console plugin (plg_console_mokobackup) — CLI commands: run, list, profiles, restore, cleanup (#29)
- Content plugin (plg_content_mokobackup) — auto-backup before extension install/update (#30)
- Actionlog plugin (plg_actionlog_mokobackup) — logs backup and profile actions to User Action Logs (#31)
- BackupEngine dispatches onMokoBackupAfterRun event for plugin listeners
- Update site notice on dashboard and post-install
### Changed
- Renamed Kickstart to MokoRestore throughout
### Fixed
- SQL update migration and error handling
- Removed orphaned scriptfile from component manifest
- Consolidated admin files into single files block
## 01.00 — 2026-06-02
### Added ### Added
- Initial package structure with component, system plugin, task plugin, and webservices plugin - Initial package structure with component, system plugin, task plugin, and webservices plugin
- Joomla Scheduled Tasks integration (plg_task_mokobackup) — create multiple tasks, each running a different backup profile on its own schedule - Joomla Scheduled Tasks integration (plg_task_mokobackup) — create multiple tasks, each running a different backup profile on its own schedule
- Individual form fields for all profile settings (no raw JSON) - Individual form fields for all profile settings (no raw JSON)
- FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification - FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification
- Google Drive uploader using OAuth2 refresh tokens and resumable upload API (5 MB chunks) - Google Drive uploader using OAuth2 refresh tokens and resumable upload API
- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16)
- RemoteUploaderInterface for pluggable storage backends - RemoteUploaderInterface for pluggable storage backends
- Remote upload integrated into BackupEngine as Step 3 after archive creation - Remote upload integrated into BackupEngine with option to delete local copy after upload
- Option to delete local copy after successful remote upload (per-profile setting)
- Restore engine with file restoration and database import - Restore engine with file restoration and database import
- Standalone Kickstart restore script (restore.php) — self-contained site restoration without Joomla, like Akeeba Kickstart - MokoRestore standalone restore script — self-contained site restoration without Joomla
- "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip - "Include Restore Script" toggle per profile
- FileRestorer class with protected file handling (preserves configuration.php, .htaccess) - FileRestorer with protected file handling (preserves configuration.php, .htaccess)
- DatabaseImporter with streaming line-by-line SQL execution and error tolerance - DatabaseImporter with streaming line-by-line SQL execution and error tolerance
- Admin dashboard quickicon widget — backup status at a glance with warnings (#18) - Admin dashboard quickicon widget — backup status at a glance with warnings (#18)
- Differential backups — only back up files changed since last full backup (#19) - Differential backups — only back up files changed since last full backup (#19)
- DifferentialScanner: builds file manifests (path/size/mtime) and compares against base - DifferentialScanner with file manifests stored in backup records
- File manifest stored in backup record for future differential comparisons
- Automatic full-backup fallback when no base manifest exists
- JPA archive format import for Akeeba Backup migration (#20) - JPA archive format import for Akeeba Backup migration (#20)
- JpaUnarchiver: parses Akeeba JPA binary format (headers, gzip, permissions)
- RestoreEngine auto-detects JPA vs ZIP format
- AES-256 archive encryption with per-profile password (#17) - AES-256 archive encryption with per-profile password (#17)
- Encrypted archive support in RestoreEngine (password parameter) - SHA-256 checksum verification for backup integrity (#15)
- Encrypted archive support in Kickstart restore.php (password field in UI)
- SHA-256 checksum computed and stored after archive creation (#15)
- "Verify Integrity" toolbar button re-computes hash and compares against stored checksum
- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16)
- S3 uploader with AWS Signature V4, single PUT for files <= 100 MB, multipart for larger
- S3 fields in profile form with showon conditional visibility
- Akeeba importer now maps S3 credentials from Akeeba profiles
- Email notifications on backup success/failure via Joomla mailer (#14) - Email notifications on backup success/failure via Joomla mailer (#14)
- Per-profile notification settings: recipient emails, notify on success/failure - Akeeba Backup Pro importer — profiles, filters, remote storage, and backup history
- Failure emails include last 30 lines of backup log for debugging
- mcp_mokobackup MCP server updated with MokoBackupClient for dual-backend support (#21)
- Akeeba Backup Pro importer — imports profiles, filters, remote storage settings, and backup history
- Auto-disables Akeeba plugins and scheduled tasks after successful import - Auto-disables Akeeba plugins and scheduled tasks after successful import
- "Import from Akeeba" toolbar button in Profiles view (only shown when Akeeba tables detected)
- Supports both INI-format and JSON-format Akeeba configuration parsing
- Maps Akeeba filter format (per-root, nested) to newline-separated exclusion fields
- Profile selector dropdown in Backup Records view for choosing which profile to run
- AJAX step-based backup engine for shared hosting (overcomes max_execution_time) - AJAX step-based backup engine for shared hosting (overcomes max_execution_time)
- SteppedBackupEngine: breaks backup into per-table DB dumps and file batches
- SteppedSession: persistent state between AJAX requests via temp JSON files
- Progress bar modal in admin UI with real-time phase/percentage updates - Progress bar modal in admin UI with real-time phase/percentage updates
- AjaxController for init/step endpoints with CSRF protection
- Per-profile archive settings: format, compression level, split size, backup directory - Per-profile archive settings: format, compression level, split size, backup directory
- Backup engine with step-based execution for large sites - Backup engine with database dumper, file scanner, and ZIP archive builder
- Database dumper with table-level granularity
- File scanner with directory exclusion filters
- ZIP archive builder
- Backup profiles with independent configurations - Backup profiles with independent configurations
- Backup record management (list, download, delete) - Backup record management (list, download, delete)
- Admin dashboard with backup history
- CLI script for cron/scheduled backups - CLI script for cron/scheduled backups
- REST API compatible with MokoBackup MCP server - REST API compatible with MokoBackup MCP server
- System plugin for automatic backup cleanup with configurable retention - System plugin for automatic backup cleanup with configurable retention
+88 -126
View File
@@ -3,43 +3,29 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# #
# MokoJoomBackup — Full-site backup and restore for Joomla # MokoJoomBackup — 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 - Customize these for your extension # CONFIGURATION
# ============================================================================== # ==============================================================================
# Extension Configuration
EXTENSION_NAME := mokobackup EXTENSION_NAME := mokobackup
EXTENSION_TYPE := package EXTENSION_TYPE := package
# Options: module, plugin, component, package, template
EXTENSION_VERSION := 1.0.0
# Module Configuration (for modules only)
MODULE_TYPE := site
# Options: site, admin
# Plugin Configuration (for plugins only)
PLUGIN_GROUP := system
# Options: system, content, user, authentication, etc.
# Directories
SRC_DIR := src SRC_DIR := src
BUILD_DIR := build
DIST_DIR := dist
DOCS_DIR := docs
# Joomla Installation (for local testing - customize paths) # Gitea
JOOMLA_ROOT := /var/www/html/joomla GITEA_URL := https://git.mokoconsulting.tech
JOOMLA_VERSION := 4 GITEA_ORG := MokoConsulting
GITEA_REPO := MokoJoomBackup
# Tools # Tools
PHP := php PHP := php
COMPOSER := composer COMPOSER := composer
NPM := npm
PHPCS := vendor/bin/phpcs PHPCS := vendor/bin/phpcs
PHPCBF := vendor/bin/phpcbf
PHPUNIT := vendor/bin/phpunit
ZIP := zip
# Coding Standards # Coding Standards
PHPCS_STANDARD := Joomla PHPCS_STANDARD := Joomla
@@ -58,146 +44,122 @@ COLOR_RED := \033[31m
.PHONY: help .PHONY: help
help: ## Show this help message help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)" @echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)Joomla Extension Makefile ║$(COLOR_RESET)" @echo "$(COLOR_BLUE)MokoJoomBackup Makefile $(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)" @echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo "" @echo ""
@echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)" @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}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}'
@echo "" @echo ""
.PHONY: install-deps # -- Validation ----------------------------------------------------------------
install-deps: ## Install all dependencies (Composer + npm)
@echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)"
@if [ -f "composer.json" ]; then \
$(COMPOSER) install; \
echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \
fi
.PHONY: lint .PHONY: lint
lint: ## Run PHP linter (syntax check) lint: ## Run PHP syntax check on all source files
@echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)" @echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)"
@find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \ @ERROR=0; \
-exec $(PHP) -l {} \; | grep -v "No syntax errors" || true 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)" @echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)"
.PHONY: phpcs .PHONY: phpcs
phpcs: ## Run PHP CodeSniffer (Joomla standards) phpcs: ## Run PHP CodeSniffer (Joomla standards)
@echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)" @echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)"
@if [ -f "$(PHPCS)" ]; then \ @if [ -f "$(PHPCS)" ]; then \
$(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \ $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php $(SRC_DIR); \
else \ else \
echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \ echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \
fi fi
.PHONY: validate .PHONY: validate
validate: lint phpcs ## Run all validation checks validate: lint ## Run all local validation checks
@echo "$(COLOR_GREEN)All validation checks passed$(COLOR_RESET)" @echo "$(COLOR_GREEN)Validation passed$(COLOR_RESET)"
.PHONY: clean .PHONY: validate-xml
clean: ## Clean build artifacts validate-xml: ## Validate all XML manifests are well-formed
@echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)" @echo "$(COLOR_BLUE)Validating XML manifests...$(COLOR_RESET)"
@rm -rf $(BUILD_DIR) $(DIST_DIR) @ERROR=0; \
@echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)" 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)) MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform))
MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js
.PHONY: minify .PHONY: minify
minify: ## Minify CSS/JS assets minify: ## Minify CSS/JS assets
@echo "Minifying assets..." @echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)"
@if [ -f "$(MINIFY_SCRIPT)" ]; then \ @if [ -f "$(MINIFY_SCRIPT)" ]; then \
node "$(MINIFY_SCRIPT)" $(SRC_DIR); \ node "$(MINIFY_SCRIPT)" $(SRC_DIR); \
elif [ -f "scripts/minify.js" ]; then \ elif [ -f "scripts/minify.js" ]; then \
node scripts/minify.js; \ node scripts/minify.js; \
else \ else \
echo "No minify script found"; \ echo "$(COLOR_YELLOW)No minify script found$(COLOR_RESET)"; \
fi fi
.PHONY: build # -- Release (CI workflow dispatch) --------------------------------------------
build: clean validate minify ## Build extension package
@echo "$(COLOR_BLUE)Building Joomla extension package...$(COLOR_RESET)"
@mkdir -p $(DIST_DIR) $(BUILD_DIR)
# Determine package prefix based on extension type
@case "$(EXTENSION_TYPE)" in \
module) \
PACKAGE_PREFIX="mod_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
plugin) \
PACKAGE_PREFIX="plg_$(PLUGIN_GROUP)_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
component) \
PACKAGE_PREFIX="com_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
package) \
PACKAGE_PREFIX="pkg_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
template) \
PACKAGE_PREFIX="tpl_$(EXTENSION_NAME)"; \
BUILD_TARGET="$(BUILD_DIR)/$$PACKAGE_PREFIX"; \
;; \
*) \
echo "$(COLOR_RED)✗ Unknown extension type: $(EXTENSION_TYPE)$(COLOR_RESET)"; \
exit 1; \
;; \
esac; \
\
mkdir -p "$$BUILD_TARGET"; \
\
echo "Building $$PACKAGE_PREFIX..."; \
\
rsync -av --progress \
--exclude='$(BUILD_DIR)' \
--exclude='$(DIST_DIR)' \
--exclude='.git*' \
--exclude='vendor/' \
--exclude='node_modules/' \
--exclude='tests/' \
--exclude='Makefile' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='phpunit.xml' \
--exclude='*.md' \
--exclude='.editorconfig' \
. "$$BUILD_TARGET/"; \
\
cd $(BUILD_DIR) && $(ZIP) -r "../$(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip" "$${PACKAGE_PREFIX}"; \
\
echo "$(COLOR_GREEN)✓ Package created: $(DIST_DIR)/$${PACKAGE_PREFIX}-$(EXTENSION_VERSION).zip$(COLOR_RESET)"
.PHONY: package
package: build ## Alias for build
@echo "$(COLOR_GREEN)✓ Package ready for distribution$(COLOR_RESET)"
.PHONY: release .PHONY: release
release: validate build ## Create a release (validate + build) release: validate validate-xml ## Trigger pre-release build via CI workflow
@echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)" @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 .PHONY: version
version: ## Display version information version: ## Display version from package manifest
@echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)" @VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokobackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
@echo " Name: $(EXTENSION_NAME)" echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
@echo " Type: $(EXTENSION_TYPE)"
@echo " Version: $(EXTENSION_VERSION)"
.PHONY: security-check
security-check: ## Run security checks 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
.PHONY: all
all: install-deps validate build ## Run complete build pipeline
@echo "$(COLOR_GREEN)✓ Complete build pipeline finished$(COLOR_RESET)"
# Default target # Default target
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomBackup # MokoJoomBackup
<!-- VERSION: 01.00.00 --> <!-- VERSION: 01.01.07 -->
Full-site backup and restore for Joomla — database, files, and configuration. Full-site backup and restore for Joomla — database, files, and configuration.
@@ -7,3 +7,4 @@
PKG_MOKOBACKUP="Package - MokoJoomBackup" PKG_MOKOBACKUP="Package - MokoJoomBackup"
PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API."
PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later."
PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -7,3 +7,4 @@
PKG_MOKOBACKUP="Package - MokoJoomBackup" PKG_MOKOBACKUP="Package - MokoJoomBackup"
PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." PKG_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API."
PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." PKG_MOKOBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later."
PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
+108
View File
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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
-->
<config>
<fieldset name="general" label="COM_MOKOBACKUP_CONFIG_GENERAL">
<field
name="default_backup_dir"
type="FolderPicker"
label="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
description="COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
default="administrator/components/com_mokobackup/backups"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/>
<field
name="default_profile"
type="sql"
label="COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE"
description="COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
query="SELECT id AS value, title AS text FROM #__mokobackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
</field>
<field
name="show_update_notice"
type="radio"
label="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE"
description="COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="cleanup" label="COM_MOKOBACKUP_CONFIG_CLEANUP">
<field
name="max_age_days"
type="number"
label="COM_MOKOBACKUP_CONFIG_MAX_AGE"
description="COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC"
default="30"
min="1"
max="365"
/>
<field
name="max_backups"
type="number"
label="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS"
description="COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC"
default="10"
min="1"
max="100"
/>
</fieldset>
<fieldset name="notifications" label="COM_MOKOBACKUP_CONFIG_NOTIFICATIONS">
<field
name="notify_email"
type="text"
label="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL"
description="COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC"
default=""
filter="string"
/>
<field
name="notify_on_success"
type="radio"
label="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS"
description="COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="notify_on_failure"
type="radio"
label="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE"
description="COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC">
<field
name="rules"
type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
filter="rules"
validate="rules"
component="com_mokobackup"
section="component"
/>
</fieldset>
</config>
+22 -14
View File
@@ -39,6 +39,7 @@
default="zip" default="zip"
> >
<option value="zip">ZIP</option> <option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
</field> </field>
<field <field
name="compression_level" name="compression_level"
@@ -63,17 +64,17 @@
/> />
<field <field
name="backup_dir" name="backup_dir"
type="text" type="FolderPicker"
label="COM_MOKOBACKUP_FIELD_BACKUP_DIR" label="COM_MOKOBACKUP_FIELD_BACKUP_DIR"
description="COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC" description="COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC"
default="administrator/components/com_mokobackup/backups" default="administrator/components/com_mokobackup/backups"
maxlength="512" addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/> />
<field <field
name="include_kickstart" name="include_mokorestore"
type="radio" type="radio"
label="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART" label="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE"
description="COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC" description="COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
default="0" default="0"
class="btn-group" class="btn-group"
> >
@@ -114,30 +115,29 @@
<fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS"> <fieldset name="filters" label="COM_MOKOBACKUP_FIELDSET_FILTERS">
<field <field
name="exclude_dirs" name="exclude_dirs"
type="textarea" type="ExcludeList"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS" label="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC" description="COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS_DESC"
rows="6"
filter="raw" filter="raw"
hint="tmp&#10;cache&#10;logs&#10;administrator/logs" hint="tmp"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/> />
<field <field
name="exclude_files" name="exclude_files"
type="textarea" type="ExcludeList"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES" label="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC" description="COM_MOKOBACKUP_FIELD_EXCLUDE_FILES_DESC"
rows="4"
filter="raw" filter="raw"
hint=".gitignore&#10;*.bak&#10;*.tmp" hint="*.bak"
addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/> />
<field <field
name="exclude_tables" name="exclude_tables"
type="textarea" type="DatabaseTables"
label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES" label="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES"
description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC" description="COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_DESC"
rows="4"
filter="raw" filter="raw"
hint="#__session&#10;#__mail_queue" addfieldprefix="Joomla\Component\MokoBackup\Administrator\Field"
/> />
</fieldset> </fieldset>
@@ -176,6 +176,14 @@
maxlength="512" maxlength="512"
hint="admin@example.com, backup@example.com" hint="admin@example.com, backup@example.com"
/> />
<field
name="notify_user_groups"
type="usergrouplist"
label="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS"
description="COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
multiple="true"
layout="joomla.form.field.list-fancy-select"
/>
<field <field
name="notify_on_success" name="notify_on_success"
type="radio" type="radio"
@@ -8,9 +8,24 @@ COM_MOKOBACKUP="MokoJoomBackup"
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
; Submenu ; Submenu
COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
; Dashboard view
COM_MOKOBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard"
COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled"
COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups"
COM_MOKOBACKUP_DASHBOARD_STORAGE="Storage Used"
COM_MOKOBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)"
COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
; Backups view ; Backups view
COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records" COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records"
COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records" COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records"
@@ -75,8 +90,8 @@ COM_MOKOBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting." COM_MOKOBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory" COM_MOKOBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Relative path from Joomla root where backup archives are stored" COM_MOKOBACKUP_FIELD_BACKUP_DIR_DESC="Relative path from Joomla root where backup archives are stored"
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART="Include Restore Script" COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
COM_MOKOBACKUP_FIELD_INCLUDE_KICKSTART_DESC="Include a standalone restore.php inside the backup archive. This creates a self-contained package that can restore the site on a blank server without Joomla installed — like Akeeba Kickstart." COM_MOKOBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
; Exclusion filter fields ; Exclusion filter fields
COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories" COM_MOKOBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
@@ -189,6 +204,49 @@ COM_MOKOBACKUP_FIELD_S3_PATH_DESC="Optional path prefix inside the bucket (e.g.
COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba" COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba"
COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?" COM_MOKOBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?"
; Update site notice
COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server."
COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
; Component Options (config.xml)
COM_MOKOBACKUP_CONFIG_GENERAL="General"
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory"
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile."
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile"
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified."
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice"
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view."
COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count"
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain."
COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications"
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)"
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this."
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success"
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)."
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
; Folder picker
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
; Exclude fields
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
; User group notifications
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
; Dashboard warnings
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
; Errors ; Errors
COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore." COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
@@ -6,10 +6,57 @@
COM_MOKOBACKUP="MokoJoomBackup" COM_MOKOBACKUP="MokoJoomBackup"
COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
COM_MOKOBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles"
COM_MOKOBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard"
COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled"
COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups"
COM_MOKOBACKUP_DASHBOARD_STORAGE="Storage Used"
COM_MOKOBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)"
COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records" COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records"
COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles" COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles"
COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
COM_MOKOBACKUP_NO_PROFILES="No backup profiles found." COM_MOKOBACKUP_NO_PROFILES="No backup profiles found."
COM_MOKOBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
COM_MOKOBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server."
COM_MOKOBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
COM_MOKOBACKUP_CONFIG_GENERAL="General"
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory"
COM_MOKOBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile."
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile"
COM_MOKOBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified."
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice"
COM_MOKOBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view."
COM_MOKOBACKUP_CONFIG_CLEANUP="Cleanup Defaults"
COM_MOKOBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)"
COM_MOKOBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command."
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count"
COM_MOKOBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain."
COM_MOKOBACKUP_CONFIG_NOTIFICATIONS="Notifications"
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)"
COM_MOKOBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this."
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success"
COM_MOKOBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)."
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root"
COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security."
COM_MOKOBACKUP_FOLDER_EXISTS="Directory exists"
COM_MOKOBACKUP_FOLDER_NOT_FOUND="Directory not found"
COM_MOKOBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)"
COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Checked tables will be skipped during dump."
COM_MOKOBACKUP_FIELD_TABLE_NAME="Table Name"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
COM_MOKOBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
+14 -37
View File
@@ -8,7 +8,7 @@
--> -->
<extension type="component" method="upgrade"> <extension type="component" method="upgrade">
<name>com_mokobackup</name> <name>com_mokobackup</name>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -19,8 +19,6 @@
<namespace path="src">Joomla\Component\MokoBackup</namespace> <namespace path="src">Joomla\Component\MokoBackup</namespace>
<scriptfile>script.php</scriptfile>
<install> <install>
<sql> <sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file> <file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
@@ -40,45 +38,24 @@
</update> </update>
<administration> <administration>
<files folder="services"> <menu img="class:archive">COM_MOKOBACKUP</menu>
<filename>provider.php</filename> <submenu>
</files> <menu link="option=com_mokobackup&amp;view=dashboard" img="class:archive">COM_MOKOBACKUP_SUBMENU_DASHBOARD</menu>
<files folder="src"> <menu link="option=com_mokobackup&amp;view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
<folder>Controller</folder> <menu link="option=com_mokobackup&amp;view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
<folder>Engine</folder> </submenu>
<folder>Extension</folder> <files folder=".">
<folder>Model</folder> <folder>cli</folder>
<folder>Table</folder> <folder>forms</folder>
<folder>View</folder> <folder>services</folder>
</files> <folder>sql</folder>
<files folder="forms"> <folder>src</folder>
<filename>backup.xml</filename> <folder>tmpl</folder>
<filename>profile.xml</filename>
<filename>filter_backups.xml</filename>
<filename>filter_profiles.xml</filename>
</files>
<files folder="tmpl">
<folder>backups</folder>
<folder>backup</folder>
<folder>profiles</folder>
<folder>profile</folder>
</files>
<files folder="sql">
<folder>mysql</folder>
<folder>updates</folder>
</files>
<files folder="cli">
<filename>mokobackup.php</filename>
</files> </files>
<languages folder="language"> <languages folder="language">
<language tag="en-GB">en-GB/com_mokobackup.ini</language> <language tag="en-GB">en-GB/com_mokobackup.ini</language>
<language tag="en-GB">en-GB/com_mokobackup.sys.ini</language> <language tag="en-GB">en-GB/com_mokobackup.sys.ini</language>
</languages> </languages>
<menu img="class:archive">COM_MOKOBACKUP</menu>
<submenu>
<menu link="option=com_mokobackup&amp;view=backups" img="class:database">COM_MOKOBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokobackup&amp;view=profiles" img="class:cog">COM_MOKOBACKUP_SUBMENU_PROFILES</menu>
</submenu>
</administration> </administration>
<api> <api>
@@ -30,8 +30,9 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` (
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive', `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails', `notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
`notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1, `notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1,
`published` TINYINT(1) NOT NULL DEFAULT 1, `published` TINYINT(1) NOT NULL DEFAULT 1,
@@ -63,8 +64,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` (
`remote_filename` VARCHAR(512) NOT NULL DEFAULT '', `remote_filename` VARCHAR(512) NOT NULL DEFAULT '',
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive', `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive',
`base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential', `base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential',
`manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison', `manifest` LONGTEXT DEFAULT NULL COMMENT 'JSON file manifest for differential comparison',
`log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', `log` MEDIUMTEXT DEFAULT NULL COMMENT 'Step-by-step backup log',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_profile` (`profile_id`), KEY `idx_profile` (`profile_id`),
KEY `idx_status` (`status`), KEY `idx_status` (`status`),
@@ -0,0 +1 @@
ALTER TABLE `#__mokobackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
@@ -0,0 +1,7 @@
-- MokoJoomBackup 01.01.08
-- Fix: allow NULL defaults for manifest and log columns
ALTER TABLE `#__mokobackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL;
ALTER TABLE `#__mokobackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
-- Add user group notifications column to profiles
ALTER TABLE `#__mokobackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
@@ -68,6 +68,63 @@ class AjaxController extends BaseController
$this->sendJson($result); $this->sendJson($result);
} }
/**
* Browse server directories for the folder picker field.
* POST: task=ajax.browseDir&path=/some/path
*/
public function browseDir(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
return;
}
$path = $this->input->getString('path', JPATH_ROOT);
$path = realpath($path) ?: $path;
if (!is_dir($path)) {
$this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]);
return;
}
// Security: only allow browsing within JPATH_ROOT or parent directories
// that could contain a backup folder (e.g., /home/user/backups)
$dirs = [];
$handle = @opendir($path);
if ($handle) {
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $path . '/' . $entry;
if (is_dir($fullPath) && $entry[0] !== '.') {
$dirs[] = [
'name' => $entry,
'path' => $fullPath,
];
}
}
closedir($handle);
}
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
$parent = dirname($path);
$this->sendJson([
'error' => false,
'current' => $path,
'parent' => ($parent !== $path) ? $parent : null,
'dirs' => $dirs,
]);
}
/** /**
* Send a JSON response and close the application. * Send a JSON response and close the application.
*/ */
@@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController;
class DisplayController extends BaseController class DisplayController extends BaseController
{ {
protected $default_view = 'backups'; protected $default_view = 'dashboard';
} }
@@ -246,7 +246,7 @@ class AkeebaImporter
's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '',
's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups',
'remote_keep_local' => 1, 'remote_keep_local' => 1,
'include_kickstart' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
'published' => 1, 'published' => 1,
'ordering' => (int) $akProfile->id, 'ordering' => (int) $akProfile->id,
'created' => $now, 'created' => $now,
@@ -0,0 +1,41 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
interface ArchiverInterface
{
/**
* Open or create the archive at the given path.
*/
public function open(string $path): void;
/**
* Add a string as a file inside the archive.
*/
public function addFromString(string $localName, string $contents): void;
/**
* Add a file from disk into the archive.
*/
public function addFile(string $filePath, string $localName): void;
/**
* Finalize and close the archive.
*/
public function close(): void;
/**
* Return the file extension for this archive type (e.g. 'zip', 'tar.gz').
*/
public function getExtension(): string;
}
@@ -13,6 +13,7 @@ namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\Event\Event;
class BackupEngine class BackupEngine
{ {
@@ -70,7 +71,10 @@ class BackupEngine
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$tag = date('Ymd_His'); $tag = date('Ymd_His');
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; $archiveFormat = $profile->archive_format ?? 'zip';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.' . $archiveExt;
if (empty($description)) { if (empty($description)) {
$description = $profile->title . ' — ' . $now; $description = $profile->title . ' — ' . $now;
@@ -104,12 +108,8 @@ class BackupEngine
$this->log('Backup started: ' . $description); $this->log('Backup started: ' . $description);
$archivePath = $this->backupDir . '/' . $archiveName; $archivePath = $this->backupDir . '/' . $archiveName;
// Create ZIP archive // Create archive
$zip = new \ZipArchive(); $archiver->open($archivePath);
if ($zip->open($archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create archive: ' . $archivePath);
}
$dbSize = 0; $dbSize = 0;
$filesCount = 0; $filesCount = 0;
@@ -120,7 +120,7 @@ class BackupEngine
$this->log('Starting database dump...'); $this->log('Starting database dump...');
$dumper = new DatabaseDumper($excludeTables); $dumper = new DatabaseDumper($excludeTables);
$sqlDump = $dumper->dump(); $sqlDump = $dumper->dump();
$zip->addFromString('database.sql', $sqlDump); $archiver->addFromString('database.sql', $sqlDump);
$dbSize = strlen($sqlDump); $dbSize = strlen($sqlDump);
$tablesCount = $dumper->getTablesCount(); $tablesCount = $dumper->getTablesCount();
$this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes');
@@ -156,7 +156,7 @@ class BackupEngine
$fullPath = JPATH_ROOT . '/' . $relativePath; $fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) { if (is_file($fullPath) && is_readable($fullPath)) {
$zip->addFile($fullPath, $relativePath); $archiver->addFile($fullPath, $relativePath);
} }
} }
@@ -169,15 +169,19 @@ class BackupEngine
} }
} }
$zip->close(); $archiver->close();
// Step 1.5: Apply AES-256 encryption (if configured) // Step 1.5: Apply AES-256 encryption (if configured)
$encryptionPassword = $profile->encryption_password ?? ''; $encryptionPassword = $profile->encryption_password ?? '';
if (!empty($encryptionPassword)) { if (!empty($encryptionPassword)) {
$this->log('Encrypting archive with AES-256...'); if ($archiveFormat !== 'zip') {
$this->encryptArchive($archivePath, $encryptionPassword); $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
$this->log('Archive encrypted'); } else {
$this->log('Encrypting archive with AES-256...');
$this->encryptArchive($archivePath, $encryptionPassword);
$this->log('Archive encrypted');
}
} }
// Record archive size and compute checksum (after encryption) // Record archive size and compute checksum (after encryption)
@@ -187,21 +191,21 @@ class BackupEngine
$this->log('Archive created: ' . $sizeHuman); $this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A')); $this->log('SHA-256: ' . ($checksum ?: 'N/A'));
// Step 2.5: Wrap with Kickstart restore script (if enabled) // Step 2.5: Wrap with MokoRestore script (if enabled)
$includeKickstart = (bool) ($profile->include_kickstart ?? false); $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
if ($includeKickstart) { if ($includeMokoRestore) {
$this->log('Wrapping with Kickstart restore script...'); $this->log('Wrapping with MokoRestore script...');
$kickstartName = str_replace('.zip', '-kickstart.zip', $archiveName); $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
$kickstartPath = $this->backupDir . '/' . $kickstartName; $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
Kickstart::wrap($archivePath, $kickstartPath); MokoRestore::wrap($archivePath, $mokoRestorePath);
// Replace the original archive with the wrapped one // Replace the original archive with the wrapped one
@unlink($archivePath); @unlink($archivePath);
rename($kickstartPath, $archivePath); rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath); $totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
$this->log('Kickstart archive created: ' . $sizeHuman); $this->log('MokoRestore archive created: ' . $sizeHuman);
} }
$remoteFilename = ''; $remoteFilename = '';
@@ -250,6 +254,9 @@ class BackupEngine
// Send success notification // Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log)); NotificationSender::send($profile, $update, true, implode("\n", $this->log));
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
return [ return [
'success' => true, 'success' => true,
'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')',
@@ -275,6 +282,9 @@ class BackupEngine
// Send failure notification // Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log)); NotificationSender::send($profile, $update, false, implode("\n", $this->log));
// 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];
} }
} }
@@ -354,6 +364,18 @@ class BackupEngine
return true; return true;
} }
/**
* Create the appropriate archiver based on the archive format.
*/
private function createArchiver(string $format): ArchiverInterface
{
return match ($format) {
'zip' => new ZipArchiver(),
'tar.gz' => new TarGzArchiver(),
default => new ZipArchiver(),
};
}
/** /**
* Create the appropriate remote uploader based on the storage type. * Create the appropriate remote uploader based on the storage type.
*/ */
@@ -445,6 +467,28 @@ class BackupEngine
)); ));
} }
/**
* Dispatch the onMokoBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void
{
try {
$app = Factory::getApplication();
$event = new Event('onMokoBackupAfterRun', [
'success' => $success,
'record_id' => $recordId,
'description' => $description,
'profile_id' => $profileId,
'origin' => $origin,
]);
$app->getDispatcher()->dispatch('onMokoBackupAfterRun', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the backup result
}
}
private function log(string $message): void private function log(string $message): void
{ {
$this->log[] = '[' . date('H:i:s') . '] ' . $message; $this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -9,7 +9,7 @@
* *
* Standalone restore script generator. * Standalone restore script generator.
* *
* When "Include Kickstart" is enabled on a profile, the backup archive * When "Include MokoRestore" is enabled on a profile, the backup archive
* is wrapped: * is wrapped:
* *
* outer.zip * outer.zip
@@ -17,14 +17,14 @@
* └── site-backup.zip The actual site backup * └── site-backup.zip The actual site backup
* *
* Upload outer.zip to a blank server, extract, open restore.php in a * Upload outer.zip to a blank server, extract, open restore.php in a
* browser, and it handles everything just like Akeeba Kickstart. * browser, and it handles everything self-contained site restoration.
*/ */
namespace Joomla\Component\MokoBackup\Administrator\Engine; namespace Joomla\Component\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die; defined('_JEXEC') or die;
class Kickstart class MokoRestore
{ {
/** /**
* Wrap a backup archive with the standalone restore script. * Wrap a backup archive with the standalone restore script.
@@ -39,7 +39,7 @@ class Kickstart
$zip = new \ZipArchive(); $zip = new \ZipArchive();
if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create kickstart archive: ' . $outputPath); throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath);
} }
// Add the standalone restore script // Add the standalone restore script
@@ -68,7 +68,7 @@ class Kickstart
return <<<'RESTORE_PHP' return <<<'RESTORE_PHP'
<?php <?php
/** /**
* MokoJoomBackup Standalone Restore Script * MokoRestore Standalone Site Restoration Tool
* *
* Upload this file alongside site-backup.zip to your server. * Upload this file alongside site-backup.zip to your server.
* Open restore.php in your browser and follow the steps. * Open restore.php in your browser and follow the steps.
@@ -33,9 +33,13 @@ class NotificationSender
*/ */
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
{ {
$notifyEmail = trim($profile->notify_email ?? ''); $notifyEmail = trim($profile->notify_email ?? '');
$notifyUserGroups = $profile->notify_user_groups ?? '';
if (empty($notifyEmail)) { // Resolve user group members to email addresses
$groupEmails = self::resolveUserGroupEmails($notifyUserGroups);
if (empty($notifyEmail) && empty($groupEmails)) {
return false; return false;
} }
@@ -54,9 +58,10 @@ class NotificationSender
$siteName = $config->get('sitename', 'Joomla Site'); $siteName = $config->get('sitename', 'Joomla Site');
$siteUrl = Uri::root(); $siteUrl = Uri::root();
// Parse recipient list (comma-separated) // Parse recipient list (comma-separated) + user group emails
$recipients = array_map('trim', explode(',', $notifyEmail)); $recipients = array_map('trim', explode(',', $notifyEmail));
$recipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)); $recipients = array_merge($recipients, $groupEmails);
$recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)));
if (empty($recipients)) { if (empty($recipients)) {
return false; return false;
@@ -133,4 +138,41 @@ class NotificationSender
return false; return false;
} }
} }
/**
* Resolve user group IDs to email addresses of group members.
*
* @param string|array $groups Comma-separated group IDs or array
*
* @return array Email addresses
*/
private static function resolveUserGroupEmails(string|array $groups): array
{
if (empty($groups)) {
return [];
}
if (\is_string($groups)) {
$groups = array_filter(array_map('intval', explode(',', $groups)));
}
if (empty($groups)) {
return [];
}
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('u.email'))
->from($db->quoteName('#__users', 'u'))
->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id')
->where($db->quoteName('u.block') . ' = 0')
->whereIn($db->quoteName('ugm.group_id'), $groups);
$db->setQuery($query);
return $db->loadColumn() ?: [];
} catch (\Throwable $e) {
return [];
}
}
} }
@@ -89,12 +89,15 @@ class RestoreEngine
// Step 1: Extract archive to staging // Step 1: Extract archive to staging
$this->log('Extracting archive: ' . basename($archivePath)); $this->log('Extracting archive: ' . basename($archivePath));
// Detect format: JPA or ZIP // Detect format: JPA, tar.gz, or ZIP
if (JpaUnarchiver::isJpaFile($archivePath)) { if (JpaUnarchiver::isJpaFile($archivePath)) {
$this->log('Detected JPA format (Akeeba Backup archive)'); $this->log('Detected JPA format (Akeeba Backup archive)');
$jpa = new JpaUnarchiver($archivePath, $this->stagingDir); $jpa = new JpaUnarchiver($archivePath, $this->stagingDir);
$count = $jpa->extract(); $count = $jpa->extract();
$this->log('Extracted ' . $count . ' files from JPA'); $this->log('Extracted ' . $count . ' files from JPA');
} elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
$this->log('Detected tar.gz format');
$this->extractTarGz($archivePath);
} else { } else {
$this->extractArchive($archivePath, $password); $this->extractArchive($archivePath, $password);
} }
@@ -200,6 +203,16 @@ class RestoreEngine
$zip->close(); $zip->close();
} }
/**
* Extract a tar.gz archive to the staging directory.
*/
private function extractTarGz(string $archivePath): void
{
$phar = new \PharData($archivePath);
$phar->extractTo($this->stagingDir, null, true);
$this->log('Extracted tar.gz archive');
}
/** /**
* Recursively delete a directory and all its contents. * Recursively delete a directory and all its contents.
*/ */
@@ -57,7 +57,7 @@ class SteppedBackupEngine
$session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); $session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? '');
$session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'; $session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups';
$session->remoteStorage = $profile->remote_storage ?? 'none'; $session->remoteStorage = $profile->remote_storage ?? 'none';
$session->includeKickstart = (bool) ($profile->include_kickstart ?? false); $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
// Build archive path // Build archive path
@@ -288,7 +288,7 @@ class SteppedBackupEngine
} }
/** /**
* Finalize phase: add database.sql to ZIP, apply kickstart wrapper. * Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper.
*/ */
private function stepFinalize(SteppedSession $session): void private function stepFinalize(SteppedSession $session): void
{ {
@@ -314,15 +314,15 @@ class SteppedBackupEngine
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
// Kickstart wrapper // MokoRestore wrapper
if ($session->includeKickstart) { if ($session->includeMokoRestore) {
$session->log('Wrapping with Kickstart restore script...'); $session->log('Wrapping with MokoRestore script...');
$kickstartPath = $session->archivePath . '.kickstart.zip'; $mokoRestorePath = $session->archivePath . '.mokorestore.zip';
Kickstart::wrap($session->archivePath, $kickstartPath); MokoRestore::wrap($session->archivePath, $mokoRestorePath);
@unlink($session->archivePath); @unlink($session->archivePath);
rename($kickstartPath, $session->archivePath); rename($mokoRestorePath, $session->archivePath);
$totalSize = filesize($session->archivePath); $totalSize = filesize($session->archivePath);
$session->log('Kickstart archive created'); $session->log('MokoRestore archive created');
} }
// Update record // Update record
@@ -51,7 +51,7 @@ class SteppedSession
public array $excludeFiles = []; public array $excludeFiles = [];
public array $excludeTables = []; public array $excludeTables = [];
public string $remoteStorage = 'none'; public string $remoteStorage = 'none';
public bool $includeKickstart = false; public bool $includeMokoRestore = false;
public bool $remoteKeepLocal = true; public bool $remoteKeepLocal = true;
// Progress // Progress
@@ -0,0 +1,63 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class TarGzArchiver implements ArchiverInterface
{
private \PharData $tar;
private string $tarPath;
public function open(string $path): void
{
// PharData creates .tar first, then we compress to .tar.gz
// Strip .gz to get the .tar path for initial creation
$this->tarPath = preg_replace('/\.gz$/', '', $path);
// Remove existing files to avoid "already exists" errors
if (is_file($this->tarPath)) {
@unlink($this->tarPath);
}
if (is_file($path)) {
@unlink($path);
}
$this->tar = new \PharData($this->tarPath);
}
public function addFromString(string $localName, string $contents): void
{
$this->tar->addFromString($localName, $contents);
}
public function addFile(string $filePath, string $localName): void
{
$this->tar->addFile($filePath, $localName);
}
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);
}
}
public function getExtension(): string
{
return 'tar.gz';
}
}
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Engine;
defined('_JEXEC') or die;
class ZipArchiver implements ArchiverInterface
{
private \ZipArchive $zip;
public function open(string $path): void
{
$this->zip = new \ZipArchive();
if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Cannot create ZIP archive: ' . $path);
}
}
public function addFromString(string $localName, string $contents): void
{
$this->zip->addFromString($localName, $contents);
}
public function addFile(string $filePath, string $localName): void
{
$this->zip->addFile($filePath, $localName);
}
public function close(): void
{
$this->zip->close();
}
public function getExtension(): string
{
return 'zip';
}
}
@@ -0,0 +1,105 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class DatabaseTablesField extends FormField
{
protected $type = 'DatabaseTables';
protected function getInput(): string
{
$db = Factory::getDbo();
$tables = $db->getTableList();
$prefix = $db->getPrefix();
// Parse current exclusions (newline-separated)
$excluded = [];
if (!empty($this->value)) {
$excluded = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))));
}
// Normalize: replace literal #__ with actual prefix for comparison
$excludedNormalized = array_map(function ($t) use ($prefix) {
return str_replace('#__', $prefix, $t);
}, $excluded);
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$html = '<div class="mb-2">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
$html .= '<table class="table table-sm table-hover mb-0">';
$html .= '<thead class="sticky-top bg-white"><tr>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleAll" /></th>';
$html .= '<th>' . Text::_('COM_MOKOBACKUP_FIELD_TABLE_NAME') . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($tables as $table) {
$isExcluded = \in_array($table, $excludedNormalized, true);
// Convert to #__ notation for storage
$storeValue = $table;
if (str_starts_with($table, $prefix)) {
$storeValue = '#__' . substr($table, \strlen($prefix));
}
$safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8');
$safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8');
$checked = $isExcluded ? ' checked' : '';
$html .= '<tr>';
$html .= '<td><input type="checkbox" class="' . $id . '_cb" value="' . $safeValue . '"' . $checked . ' /></td>';
$html .= '<td><code>' . $safeTable . '</code></td>';
$html .= '</tr>';
}
$html .= '</tbody></table></div></div>';
// Script to sync checkboxes to hidden field
$html .= <<<SCRIPT
<script>
(function() {
var hidden = document.getElementById('{$id}');
var cbs = document.querySelectorAll('.{$id}_cb');
var toggleAll = document.getElementById('{$id}_toggleAll');
function sync() {
var vals = [];
cbs.forEach(function(cb) { if (cb.checked) vals.push(cb.value); });
hidden.value = vals.join('\\n');
}
cbs.forEach(function(cb) { cb.addEventListener('change', sync); });
toggleAll.addEventListener('change', function() {
var state = this.checked;
cbs.forEach(function(cb) { cb.checked = state; });
sync();
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}
@@ -0,0 +1,120 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class ExcludeListField extends FormField
{
protected $type = 'ExcludeList';
protected function getInput(): string
{
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8');
// Parse current values (newline-separated)
$items = [];
if (!empty($this->value)) {
$items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))));
}
$html = '<div id="' . $id . '_wrapper">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
$html .= '<tbody>';
foreach ($items as $item) {
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
$html .= '<tr>';
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
$html .= '</div>';
$html .= <<<SCRIPT
<script>
(function() {
var wrapper = document.getElementById('{$id}_wrapper');
var hidden = document.getElementById('{$id}');
var tbody = document.querySelector('#{$id}_table tbody');
var addBtn = document.getElementById('{$id}_add');
var placeholder = '{$placeholder}';
function sync() {
var vals = [];
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
var v = inp.value.trim();
if (v) vals.push(v);
});
hidden.value = vals.join('\\n');
}
function addRow(value) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm {$id}_input';
inp.value = value || '';
inp.placeholder = placeholder;
inp.addEventListener('input', sync);
td1.appendChild(inp);
var td2 = document.createElement('td');
td2.className = 'w-1';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
var icon = document.createElement('span');
icon.className = 'icon-delete';
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
btn.addEventListener('click', function() { tr.remove(); sync(); });
td2.appendChild(btn);
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
inp.focus();
}
addBtn.addEventListener('click', function() { addRow(''); });
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
inp.addEventListener('input', sync);
});
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
btn.addEventListener('click', function() {
btn.closest('tr').remove();
sync();
});
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}
@@ -0,0 +1,170 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class FolderPickerField extends FormField
{
protected $type = 'FolderPicker';
protected function getInput(): string
{
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
$jRoot = JPATH_ROOT;
// Resolve to absolute for display
$rawValue = $this->value ?: $this->default;
if ($rawValue && $rawValue[0] !== '/') {
$absPath = $jRoot . '/' . $rawValue;
} else {
$absPath = $rawValue;
}
$exists = is_dir($absPath);
$statusClass = $exists ? 'text-success' : 'text-danger';
$statusIcon = $exists ? 'icon-publish' : 'icon-unpublish';
$statusText = $exists
? Text::_('COM_MOKOBACKUP_FOLDER_EXISTS')
: Text::_('COM_MOKOBACKUP_FOLDER_NOT_FOUND');
$absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
return <<<HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/home/user/backups or administrator/components/com_mokobackup/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
</div>
<div class="mt-1">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
{$statusText}: <code>{$absPathSafe}</code>
</small>
</div>
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
<div class="card-body p-2">
<div id="{$id}_tree"></div>
</div>
</div>
<script>
(function() {
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
var tree = document.getElementById(fieldId + '_tree');
var input = document.getElementById(fieldId);
btn.addEventListener('click', function() {
if (browser.style.display !== 'none') {
browser.style.display = 'none';
return;
}
browser.style.display = 'block';
loadDir(input.value || '/');
});
function loadDir(path) {
tree.textContent = 'Loading...';
var form = new URLSearchParams();
form.append('task', 'ajax.browseDir');
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokobackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
tree.textContent = data.message || 'Error loading directory';
return;
}
renderTree(data, path);
})
.catch(function(err) {
tree.textContent = 'Error: ' + err.message;
});
}
function renderTree(data, path) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
if (data.parent) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' ..'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadDir(data.parent);
});
list.appendChild(up);
}
(data.dirs || []).forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var icon = document.createElement('span');
icon.className = 'icon-folder';
icon.setAttribute('aria-hidden', 'true');
item.appendChild(icon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
input.value = dir.path;
loadDir(dir.path);
});
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = dir.path;
browser.style.display = 'none';
});
list.appendChild(item);
});
tree.appendChild(list);
var info = document.createElement('div');
info.className = 'mt-2 p-1';
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = 'Current: ' + (data.current || path);
info.appendChild(small);
tree.appendChild(info);
}
})();
</script>
HTML;
}
}
@@ -0,0 +1,185 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\Model;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Model\BaseDatabaseModel;
class DashboardModel extends BaseDatabaseModel
{
/**
* Get the most recent completed backup record.
*
* @return object|null
*/
public function getLastBackup(): ?object
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select('r.*, p.title AS profile_title')
->from($db->quoteName('#__mokobackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('r.backupend') . ' DESC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: null;
}
/**
* Query com_scheduler for the next scheduled MokoBackup task.
*
* @return object|null Object with next_execution and title, or null
*/
public function getNextScheduled(): ?object
{
$db = $this->getDatabase();
try {
$query = $db->getQuery(true)
->select($db->quoteName(['t.next_execution', 't.title']))
->from($db->quoteName('#__scheduler_tasks', 't'))
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokobackup.run_profile'))
->where($db->quoteName('t.state') . ' = 1')
->order($db->quoteName('t.next_execution') . ' ASC');
$db->setQuery($query, 0, 1);
return $db->loadObject() ?: null;
} catch (\Throwable $e) {
return null;
}
}
/**
* Get backup statistics.
*
* @return object Object with total_count, total_size, fail_count_7d
*/
public function getStats(): object
{
$db = $this->getDatabase();
// Total completed backups and storage
$query = $db->getQuery(true)
->select('COUNT(*) AS total_count')
->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$stats = $db->loadObject();
// Failures in last 7 days
$cutoff = date('Y-m-d H:i:s', strtotime('-7 days'));
$query = $db->getQuery(true)
->select('COUNT(*) AS fail_count')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('fail'))
->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff));
$db->setQuery($query);
$stats->fail_count_7d = (int) $db->loadResult();
return $stats;
}
/**
* Check system health for backup readiness.
*
* @return array Array of check results [{label, status, detail}]
*/
public function getSystemHealth(): array
{
$checks = [];
// PHP version
$checks[] = (object) [
'label' => 'PHP Version',
'status' => version_compare(PHP_VERSION, '8.1.0', '>='),
'detail' => PHP_VERSION,
];
// ZipArchive extension
$checks[] = (object) [
'label' => 'ZipArchive',
'status' => extension_loaded('zip'),
'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded',
];
// AES-256 encryption support
$aesSupport = defined('ZipArchive::EM_AES_256');
$checks[] = (object) [
'label' => 'AES-256 Encryption',
'status' => $aesSupport,
'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+',
];
// Backup directory writable
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
$writable = is_dir($backupDir) && is_writable($backupDir);
$checks[] = (object) [
'label' => 'Backup Directory',
'status' => $writable,
'detail' => $writable ? 'Writable' : 'Not writable or missing',
];
// Disk space
$freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT);
$freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0;
$checks[] = (object) [
'label' => 'Free Disk Space',
'status' => $freeGB >= 1.0,
'detail' => $freeGB . ' GB free',
];
return $checks;
}
/**
* Check if any profiles use the default (web-root) backup directory.
*
* @return bool
*/
public function isUsingDefaultBackupDir(): bool
{
$db = $this->getDatabase();
$default = 'administrator/components/com_mokobackup/backups';
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokobackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default)
. ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('')
. ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)');
$db->setQuery($query);
return (int) $db->loadResult() > 0;
}
/**
* Get published backup profiles for the quick-action selector.
*
* @return array
*/
public function getProfiles(): array
{
$db = $this->getDatabase();
$query = $db->getQuery(true)
->select($db->quoteName(['id', 'title', 'backup_type']))
->from($db->quoteName('#__mokobackup_profiles'))
->where($db->quoteName('published') . ' = 1')
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Toolbar\ToolbarHelper; use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView class HtmlView extends BaseHtmlView
@@ -44,11 +45,62 @@ class HtmlView extends BaseHtmlView
$db->setQuery($query); $db->setQuery($query);
$this->profiles = $db->loadObjectList() ?: []; $this->profiles = $db->loadObjectList() ?: [];
$this->checkUpdateSite();
$this->addToolbar(); $this->addToolbar();
parent::display($tpl); parent::display($tpl);
} }
/**
* Show an info notice linking to the update site record so the user
* can configure their download key for automatic updates.
*/
protected function checkUpdateSite(): void
{
try {
$db = Factory::getDbo();
// Find the update site ID linked to pkg_mokobackup
$query = $db->getQuery(true)
->select($db->quoteName('us.update_site_id'))
->from($db->quoteName('#__update_sites', 'us'))
->join(
'INNER',
$db->quoteName('#__update_sites_extensions', 'use')
. ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
)
->join(
'INNER',
$db->quoteName('#__extensions', 'e')
. ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
)
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup'))
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
->setLimit(1);
$db->setQuery($query);
$updateSiteId = (int) $db->loadResult();
if ($updateSiteId > 0) {
$editUrl = Route::_(
'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId
);
Factory::getApplication()->enqueueMessage(
Text::sprintf('COM_MOKOBACKUP_UPDATE_SITE_NOTICE', $editUrl),
'info'
);
} else {
Factory::getApplication()->enqueueMessage(
Text::_('COM_MOKOBACKUP_UPDATE_SITE_MISSING'),
'warning'
);
}
} catch (\Throwable $e) {
// Non-critical — silently ignore
}
}
protected function addToolbar(): void protected function addToolbar(): void
{ {
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database'); ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database');
@@ -0,0 +1,50 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @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\MokoBackup\Administrator\View\Dashboard;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
use Joomla\CMS\Toolbar\ToolbarHelper;
class HtmlView extends BaseHtmlView
{
public ?object $lastBackup = null;
public ?object $nextScheduled = null;
public object $stats;
public array $systemHealth = [];
public array $profiles = [];
public bool $defaultDirWarning = false;
public function display($tpl = null): void
{
/** @var \Joomla\Component\MokoBackup\Administrator\Model\DashboardModel $model */
$model = $this->getModel();
$this->lastBackup = $model->getLastBackup();
$this->nextScheduled = $model->getNextScheduled();
$this->stats = $model->getStats();
$this->systemHealth = $model->getSystemHealth();
$this->profiles = $model->getProfiles();
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
$this->addToolbar();
parent::display($tpl);
}
protected function addToolbar(): void
{
ToolbarHelper::title(Text::_('COM_MOKOBACKUP_DASHBOARD_TITLE'), 'archive');
ToolbarHelper::preferences('com_mokobackup');
}
}
@@ -0,0 +1,278 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\HTML\HTMLHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
use Joomla\CMS\Session\Session;
$ajaxToken = Session::getFormToken();
$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false);
?>
<?php if ($this->defaultDirWarning) : ?>
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
<span class="icon-warning-circle fs-4 me-3" aria-hidden="true"></span>
<div>
<strong><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE'); ?></strong><br>
<?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_DEFAULT_DIR_WARNING'); ?>
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=profiles'); ?>" class="alert-link">
<?php echo Text::_('COM_MOKOBACKUP_SUBMENU_PROFILES'); ?>
</a>
</div>
</div>
<?php endif; ?>
<div class="row">
<!-- Row 1: Status Cards -->
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-database fs-1 text-primary" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_LAST_BACKUP'); ?></h5>
<?php if ($this->lastBackup) : ?>
<p class="card-text text-success fw-bold">
<?php echo HTMLHelper::_('date', $this->lastBackup->backupend, Text::_('DATE_FORMAT_LC4')); ?>
</p>
<small class="text-muted">
<?php echo $this->escape($this->lastBackup->profile_title); ?>
&mdash;
<?php echo HTMLHelper::_('number.bytes', $this->lastBackup->total_size); ?>
</small>
<?php else : ?>
<p class="card-text text-warning"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-calendar fs-1 text-info" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NEXT_SCHEDULED'); ?></h5>
<?php if ($this->nextScheduled) : ?>
<p class="card-text fw-bold">
<?php echo HTMLHelper::_('date', $this->nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
</p>
<small class="text-muted"><?php echo $this->escape($this->nextScheduled->title); ?></small>
<?php else : ?>
<p class="card-text text-muted"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_NO_SCHEDULED'); ?></p>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-copy fs-1 text-secondary" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_TOTAL_BACKUPS'); ?></h5>
<p class="card-text fw-bold fs-3"><?php echo (int) $this->stats->total_count; ?></p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<span class="icon-folder-open fs-1 text-warning" aria-hidden="true"></span>
<h5 class="card-title mt-2"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_STORAGE'); ?></h5>
<p class="card-text fw-bold fs-3">
<?php echo HTMLHelper::_('number.bytes', (int) $this->stats->total_size); ?>
</p>
<?php if ($this->stats->fail_count_7d > 0) : ?>
<span class="badge bg-danger">
<?php echo Text::sprintf('COM_MOKOBACKUP_DASHBOARD_FAILURES_7D', $this->stats->fail_count_7d); ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Row 2: Quick Actions -->
<div class="row mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_QUICK_ACTIONS'); ?></h5>
</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;">
<?php foreach ($this->profiles as $profile) : ?>
<option value="<?php echo (int) $profile->id; ?>">
<?php echo $this->escape($profile->title); ?>
(<?php echo $this->escape($profile->backup_type); ?>)
</option>
<?php endforeach; ?>
</select>
<button type="button" class="btn btn-primary" onclick="window.mokobackupStart()">
<span class="icon-download" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW'); ?>
</button>
</div>
<?php endif; ?>
<div class="list-group">
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=backups'); ?>" class="list-group-item list-group-item-action">
<span class="icon-database" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_SUBMENU_BACKUPS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_mokobackup&view=profiles'); ?>" class="list-group-item list-group-item-action">
<span class="icon-cog" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_SUBMENU_PROFILES'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_scheduler&view=tasks'); ?>" class="list-group-item list-group-item-action">
<span class="icon-calendar" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_SCHEDULED_TASKS'); ?>
</a>
<a href="<?php echo Route::_('index.php?option=com_installer&view=updatesites'); ?>" class="list-group-item list-group-item-action">
<span class="icon-refresh" aria-hidden="true"></span>
<?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_UPDATE_SITE'); ?>
</a>
</div>
</div>
</div>
</div>
<!-- Row 2 right: System Health -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0"><?php echo Text::_('COM_MOKOBACKUP_DASHBOARD_SYSTEM_HEALTH'); ?></h5>
</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tbody>
<?php foreach ($this->systemHealth as $check) : ?>
<tr>
<td class="w-1 text-center">
<?php if ($check->status) : ?>
<span class="icon-publish text-success" aria-hidden="true"></span>
<?php else : ?>
<span class="icon-unpublish text-danger" aria-hidden="true"></span>
<?php endif; ?>
</td>
<td><?php echo $this->escape($check->label); ?></td>
<td class="text-muted"><?php echo $this->escape($check->detail); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Stepped Backup Modal (reused from backups view) -->
<div id="mokobackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<script>
(function() {
const AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
const TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
function showModal() {
document.getElementById('mokobackup-modal').style.display = 'block';
}
function hideModal() {
document.getElementById('mokobackup-modal').style.display = 'none';
}
function updateProgress(progress, message, phase) {
const bar = document.getElementById('mb-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-status').textContent = message;
document.getElementById('mb-phase').textContent = 'Phase: ' + phase;
}
async function postAjax(params) {
const form = new URLSearchParams();
form.append(TOKEN_NAME, '1');
for (const [k, v] of Object.entries(params)) {
form.append(k, v);
}
const res = await fetch(AJAX_URL, {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
return res.json();
}
async function startSteppedBackup() {
const profileSelect = document.getElementById('mb-profile-select');
const profileId = profileSelect ? profileSelect.value : '1';
showModal();
updateProgress(0, 'Initializing backup...', 'init');
try {
const initResult = await postAjax({
task: 'ajax.init',
profile_id: profileId
});
if (initResult.error) {
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
setTimeout(hideModal, 3000);
return;
}
const sessionId = initResult.session_id;
updateProgress(initResult.progress, initResult.message, initResult.phase);
let done = false;
while (!done) {
const stepResult = await postAjax({
task: 'ajax.step',
session_id: sessionId
});
if (stepResult.error) {
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
setTimeout(hideModal, 5000);
return;
}
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-modal-title').textContent = 'Backup Complete';
setTimeout(function() {
hideModal();
location.reload();
}, 2000);
} catch (err) {
updateProgress(0, 'ERROR: ' + err.message, 'failed');
setTimeout(hideModal, 5000);
}
}
window.mokobackupStart = startSteppedBackup;
})();
</script>
@@ -0,0 +1,9 @@
; MokoJoomBackup — Actionlog Plugin language file (en-GB)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
@@ -0,0 +1,3 @@
; MokoJoomBackup — Actionlog Plugin system language file (en-GB)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
@@ -0,0 +1,9 @@
; MokoJoomBackup — Actionlog Plugin language file (en-US)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED="User {username} created backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED="User {username} updated backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED="User {username} deleted backup profile &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED="User {username} deleted backup record &quot;{title}&quot; (ID: {id})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE="Backup completed: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED="Backup FAILED: &quot;{title}&quot; (ID: {id}, profile: {profile_id}, origin: {origin})"
@@ -0,0 +1,3 @@
; MokoJoomBackup — Actionlog Plugin system language file (en-US)
PLG_ACTIONLOG_MOKOBACKUP="Action Log - MokoJoomBackup"
PLG_ACTIONLOG_MOKOBACKUP_DESCRIPTION="Logs MokoJoomBackup actions (backup, restore, profile changes) to User Action Logs."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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;
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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="actionlog" method="upgrade">
<name>plg_actionlog_mokobackup</name>
<version>01.01.07-dev</version>
<creationDate>2026-06-04</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_ACTIONLOG_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Actionlog\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_actionlog_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_actionlog_mokobackup.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Actionlog\MokoBackup\Extension\MokoBackupActionlog;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupActionlog(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('actionlog', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,174 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_actionlog_mokobackup
* @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\Plugin\Actionlog\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Event\Model;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Actionlogs\Administrator\Helper\ActionlogsHelper;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class MokoBackupActionlog extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onContentAfterSave' => 'onContentAfterSave',
'onContentAfterDelete' => 'onContentAfterDelete',
'onMokoBackupAfterRun' => 'onMokoBackupAfterRun',
];
}
/**
* Log when a backup profile is saved (created or updated).
*/
public function onContentAfterSave(Event $event): void
{
[$context, $table, $isNew] = array_values($event->getArguments());
if ($context !== 'com_mokobackup.profile') {
return;
}
$messageKey = $isNew
? 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_CREATED'
: 'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_UPDATED';
$this->addLog(
[
$messageKey,
'id' => $table->id,
'title' => $table->title,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokobackup.profile',
$this->getCurrentUserId()
);
}
/**
* Log when a backup profile or record is deleted.
*/
public function onContentAfterDelete(Event $event): void
{
[$context, $table] = array_values($event->getArguments());
if ($context === 'com_mokobackup.profile') {
$this->addLog(
[
'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
'id' => $table->id,
'title' => $table->title ?? '',
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
'PLG_ACTIONLOG_MOKOBACKUP_PROFILE_DELETED',
'com_mokobackup.profile',
$this->getCurrentUserId()
);
} elseif ($context === 'com_mokobackup.backup') {
$this->addLog(
[
'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
'id' => $table->id,
'title' => $table->description ?? 'Backup #' . $table->id,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
'PLG_ACTIONLOG_MOKOBACKUP_RECORD_DELETED',
'com_mokobackup.backup',
$this->getCurrentUserId()
);
}
}
/**
* Log when a backup completes or fails.
* This event should be dispatched from BackupEngine.
*/
public function onMokoBackupAfterRun(Event $event): void
{
$args = $event->getArguments();
$success = $args['success'] ?? false;
$recordId = $args['record_id'] ?? 0;
$description = $args['description'] ?? '';
$profileId = $args['profile_id'] ?? 0;
$origin = $args['origin'] ?? 'backend';
$messageKey = $success
? 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_COMPLETE'
: 'PLG_ACTIONLOG_MOKOBACKUP_BACKUP_FAILED';
$this->addLog(
[
$messageKey,
'id' => $recordId,
'title' => $description ?: 'Backup #' . $recordId,
'profile_id' => $profileId,
'origin' => $origin,
'userid' => $this->getCurrentUserId(),
'username' => $this->getCurrentUserName(),
],
$messageKey,
'com_mokobackup.backup',
$this->getCurrentUserId()
);
}
/**
* Write an action log entry.
*/
private function addLog(array $message, string $messageLanguageKey, string $context, int $userId): void
{
$params = [
'message_language_key' => $messageLanguageKey,
'message' => json_encode($message),
'date' => date('Y-m-d H:i:s'),
'extension' => 'com_mokobackup',
'user_id' => $userId,
'ip_address' => ActionlogsHelper::getIp(),
'item_id' => $message['id'] ?? 0,
];
try {
$db = Factory::getDbo();
$db->insertObject('#__action_logs', (object) $params);
} catch (\Throwable $e) {
// Non-critical — don't break the operation
}
}
private function getCurrentUserId(): int
{
try {
return (int) Factory::getApplication()->getIdentity()->id;
} catch (\Throwable $e) {
return 0;
}
}
private function getCurrentUserName(): string
{
try {
return Factory::getApplication()->getIdentity()->username ?: 'system';
} catch (\Throwable $e) {
return 'system';
}
}
}
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin language file (en-GB)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin system language file (en-GB)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin language file (en-US)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Console Plugin system language file (en-US)
PLG_CONSOLE_MOKOBACKUP="Console - MokoJoomBackup"
PLG_CONSOLE_MOKOBACKUP_DESCRIPTION="CLI commands for MokoJoomBackup: run, list, profiles, restore, cleanup."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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;
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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="console" method="upgrade">
<name>plg_console_mokobackup</name>
<version>01.01.07-dev</version>
<creationDate>2026-06-04</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_CONSOLE_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Console\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_console_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_console_mokobackup.sys.ini</language>
</languages>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Console\MokoBackup\Extension\MokoBackupConsole;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupConsole(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('console', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,125 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CleanupCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:cleanup';
protected function configure(): void
{
$this->setDescription('Clean up old backup records and archive files');
$this->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'Max age in days', '30');
$this->addOption('max-count', null, InputOption::VALUE_REQUIRED, 'Max number of backups to keep', '10');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be deleted without deleting');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$maxAge = (int) $input->getOption('max-age');
$maxCount = (int) $input->getOption('max-count');
$dryRun = $input->getOption('dry-run');
$io->title('MokoJoomBackup — Cleanup');
if ($dryRun) {
$io->note('Dry run — no files will be deleted.');
}
$db = Factory::getDbo();
$deleted = 0;
// Delete by age
$cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days"));
$query = $db->getQuery(true)
->select('id, absolute_path, description, backupstart')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$expired = $db->loadObjectList();
foreach ($expired as $record) {
$io->text('Expired: #' . $record->id . ' — ' . $record->backupstart . ' — ' . ($record->description ?: 'no description'));
if (!$dryRun) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
$deleted++;
}
// Enforce max count
$query = $db->getQuery(true)
->select('COUNT(*)')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
$db->setQuery($query);
$totalCount = (int) $db->loadResult();
if ($totalCount > $maxCount) {
$excess = $totalCount - $maxCount;
$query = $db->getQuery(true)
->select('id, absolute_path, description, backupstart')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->order($db->quoteName('backupstart') . ' ASC');
$db->setQuery($query, 0, $excess);
$oldest = $db->loadObjectList();
foreach ($oldest as $record) {
$io->text('Over limit: #' . $record->id . ' — ' . $record->backupstart);
if (!$dryRun) {
if (!empty($record->absolute_path) && is_file($record->absolute_path)) {
@unlink($record->absolute_path);
}
$db->setQuery(
$db->getQuery(true)
->delete($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $record->id)
);
$db->execute();
}
$deleted++;
}
}
if ($deleted === 0) {
$io->success('No backups to clean up.');
} else {
$io->success(($dryRun ? 'Would delete ' : 'Deleted ') . $deleted . ' backup record(s).');
}
return 0;
}
}
@@ -0,0 +1,87 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ListCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:list';
protected function configure(): void
{
$this->setDescription('List backup records');
$this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Number of records to show', '20');
$this->addOption('status', 's', InputOption::VALUE_OPTIONAL, 'Filter by status (complete, fail, running)');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$limit = (int) $input->getOption('limit');
$status = $input->getOption('status');
$io->title('MokoJoomBackup — Backup Records');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('r.id, r.description, r.status, r.origin, r.backup_type, r.total_size, r.backupstart, r.backupend')
->select($db->quoteName('p.title', 'profile_title'))
->from($db->quoteName('#__mokobackup_records', 'r'))
->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = r.profile_id')
->order($db->quoteName('r.backupstart') . ' DESC');
if ($status) {
$query->where($db->quoteName('r.status') . ' = ' . $db->quote($status));
}
$db->setQuery($query, 0, $limit);
$records = $db->loadObjectList();
if (empty($records)) {
$io->info('No backup records found.');
return 0;
}
$rows = [];
foreach ($records as $record) {
$size = $record->total_size > 0
? round($record->total_size / 1048576, 2) . ' MB'
: '—';
$rows[] = [
$record->id,
$record->profile_title ?: '—',
$record->status,
$record->backup_type,
$size,
$record->origin,
$record->backupstart,
];
}
$io->table(
['ID', 'Profile', 'Status', 'Type', 'Size', 'Origin', 'Started'],
$rows
);
return 0;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class ProfilesCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:profiles';
protected function configure(): void
{
$this->setDescription('List available backup profiles');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('MokoJoomBackup — Backup Profiles');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('id, title, backup_type, published, ordering')
->from($db->quoteName('#__mokobackup_profiles'))
->order($db->quoteName('ordering') . ' ASC');
$db->setQuery($query);
$profiles = $db->loadObjectList();
if (empty($profiles)) {
$io->info('No backup profiles found.');
return 0;
}
$rows = [];
foreach ($profiles as $profile) {
$rows[] = [
$profile->id,
$profile->title,
$profile->backup_type,
$profile->published ? 'Yes' : 'No',
];
}
$io->table(
['ID', 'Title', 'Type', 'Published'],
$rows
);
return 0;
}
}
@@ -0,0 +1,101 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class RestoreCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:restore';
protected function configure(): void
{
$this->setDescription('Restore a backup by record ID');
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$recordId = (int) $input->getArgument('id');
$io->title('MokoJoomBackup — Restore Backup');
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokobackup_records'))
->where($db->quoteName('id') . ' = ' . $recordId);
$db->setQuery($query);
$record = $db->loadObject();
if (!$record) {
$io->error('Backup record not found: ' . $recordId);
return 1;
}
if ($record->status !== 'complete') {
$io->error('Cannot restore — backup status is: ' . $record->status);
return 1;
}
if (empty($record->absolute_path) || !is_file($record->absolute_path)) {
$io->error('Backup archive not found: ' . ($record->absolute_path ?: 'no path'));
return 1;
}
$io->warning('This will overwrite the current site files and/or database.');
$io->text('Archive: ' . $record->absolute_path);
$io->text('Type: ' . $record->backup_type);
if (!$io->confirm('Are you sure you want to continue?', false)) {
$io->info('Restore cancelled.');
return 0;
}
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/RestoreEngine.php';
if (!file_exists($engineFile)) {
$io->error('RestoreEngine not found. Is the component fully installed?');
return 1;
}
if (!class_exists(RestoreEngine::class)) {
require_once $engineFile;
}
$engine = new RestoreEngine();
$result = $engine->restore($record->absolute_path, $record->backup_type);
if ($result['success']) {
$io->success($result['message']);
return 0;
}
$io->error($result['message']);
return 1;
}
}
@@ -0,0 +1,68 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class RunCommand extends AbstractCommand
{
protected static $defaultName = 'mokobackup:run';
protected function configure(): void
{
$this->setDescription('Run a backup using a specified profile');
$this->addOption('profile', 'p', InputOption::VALUE_REQUIRED, 'Profile ID to use', '1');
$this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Backup description', '');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$profileId = (int) $input->getOption('profile');
$desc = $input->getOption('description') ?: '';
$io->title('MokoJoomBackup — Run Backup');
$io->text('Profile ID: ' . $profileId);
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
if (!file_exists($engineFile)) {
$io->error('MokoJoomBackup component not installed.');
return 1;
}
if (!class_exists(BackupEngine::class)) {
require_once $engineFile;
}
$engine = new BackupEngine();
$result = $engine->run($profileId, $desc ?: 'CLI backup', 'cli');
if ($result['success']) {
$io->success($result['message']);
return 0;
}
$io->error($result['message']);
return 1;
}
}
@@ -0,0 +1,45 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_console_mokobackup
* @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\Plugin\Console\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\Plugin\Console\MokoBackup\Command\CleanupCommand;
use Joomla\Plugin\Console\MokoBackup\Command\ListCommand;
use Joomla\Plugin\Console\MokoBackup\Command\ProfilesCommand;
use Joomla\Plugin\Console\MokoBackup\Command\RestoreCommand;
use Joomla\Plugin\Console\MokoBackup\Command\RunCommand;
final class MokoBackupConsole extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
\Joomla\Application\ApplicationEvents::BEFORE_EXECUTE => 'registerCommands',
];
}
public function registerCommands(Event $event): void
{
$app = $this->getApplication();
$app->addCommand(new RunCommand());
$app->addCommand(new ListCommand());
$app->addCommand(new ProfilesCommand());
$app->addCommand(new RestoreCommand());
$app->addCommand(new CleanupCommand());
}
}
@@ -0,0 +1,9 @@
; MokoJoomBackup — Content Plugin language file (en-GB)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Content Plugin system language file (en-GB)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
@@ -0,0 +1,9 @@
; MokoJoomBackup — Content Plugin language file (en-US)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL="Backup Before Install"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC="Run an automatic backup before a new extension is installed."
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE="Backup Before Update"
PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC="Run an automatic backup before an extension is updated."
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE="Backup Profile"
PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC="Which backup profile to use for automatic backups."
@@ -0,0 +1,3 @@
; MokoJoomBackup — Content Plugin system language file (en-US)
PLG_CONTENT_MOKOBACKUP="Content - MokoJoomBackup"
PLG_CONTENT_MOKOBACKUP_DESCRIPTION="Automatically triggers a backup before extension installs or updates."
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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;
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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="content" method="upgrade">
<name>plg_content_mokobackup</name>
<version>01.01.07-dev</version>
<creationDate>2026-06-04</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_CONTENT_MOKOBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\Content\MokoBackup</namespace>
<files>
<filename plugin="mokobackup">mokobackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_content_mokobackup.ini</language>
<language tag="en-GB">language/en-GB/plg_content_mokobackup.sys.ini</language>
</languages>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="backup_before_install"
type="radio"
label="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL"
description="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_INSTALL_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="backup_before_update"
type="radio"
label="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE"
description="PLG_CONTENT_MOKOBACKUP_FIELD_BEFORE_UPDATE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="profile_id"
type="sql"
label="PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE"
description="PLG_CONTENT_MOKOBACKUP_FIELD_PROFILE_DESC"
query="SELECT id AS value, title AS text FROM #__mokobackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
</field>
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\Content\MokoBackup\Extension\MokoBackupContent;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoBackupContent(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('content', 'mokobackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,95 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage plg_content_mokobackup
* @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\Plugin\Content\MokoBackup\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class MokoBackupContent extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onExtensionBeforeInstall' => 'onExtensionBeforeInstall',
'onExtensionBeforeUpdate' => 'onExtensionBeforeUpdate',
];
}
/**
* Trigger a backup before a new extension is installed.
*/
public function onExtensionBeforeInstall(Event $event): void
{
if (!(int) $this->params->get('backup_before_install', 0)) {
return;
}
$this->triggerAutoBackup('Pre-install backup');
}
/**
* Trigger a backup before an extension is updated.
*/
public function onExtensionBeforeUpdate(Event $event): void
{
if (!(int) $this->params->get('backup_before_update', 1)) {
return;
}
$this->triggerAutoBackup('Pre-update backup');
}
/**
* Run a backup using the configured profile.
*/
private function triggerAutoBackup(string $description): void
{
$profileId = (int) $this->params->get('profile_id', 1);
// Throttle: only one auto-backup per hour via session
$session = Factory::getSession();
$lastRun = $session->get('mokobackup.content_last_autobackup', 0);
if (time() - $lastRun < 3600) {
return;
}
$session->set('mokobackup.content_last_autobackup', time());
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokobackup/src/Engine/BackupEngine.php';
if (!file_exists($engineFile)) {
return;
}
if (!class_exists(BackupEngine::class)) {
require_once $engineFile;
}
try {
$engine = new BackupEngine();
$engine->run($profileId, $description, 'backend');
} catch (\Throwable $e) {
// Non-fatal — log and continue with the install/update
Factory::getApplication()->enqueueMessage(
'MokoJoomBackup auto-backup failed: ' . $e->getMessage(),
'warning'
);
}
}
}
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade"> <extension type="plugin" group="quickicon" method="upgrade">
<name>plg_quickicon_mokobackup</name> <name>plg_quickicon_mokobackup</name>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -15,6 +15,7 @@ namespace Joomla\Plugin\Quickicon\MokoBackup\Extension;
defined('_JEXEC') or die; defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Plugin\CMSPlugin; use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event; use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface; use Joomla\Event\SubscriberInterface;
@@ -96,7 +97,7 @@ final class MokoBackupQuickicon extends CMSPlugin implements SubscriberInterface
'link' => 'index.php?option=com_mokobackup&view=backups', 'link' => 'index.php?option=com_mokobackup&view=backups',
'image' => $warning ? 'icon-warning' : 'icon-database', 'image' => $warning ? 'icon-warning' : 'icon-database',
'icon' => $warning ? 'icon-warning' : 'icon-database', 'icon' => $warning ? 'icon-warning' : 'icon-database',
'text' => $text, 'text' => Text::_($text),
'linkadd' => $subtitle ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '', 'linkadd' => $subtitle ? '<br><small>' . htmlspecialchars($subtitle) . '</small>' : '',
'id' => 'plg_quickicon_mokobackup', 'id' => 'plg_quickicon_mokobackup',
'group' => 'MOD_QUICKICON_MAINTENANCE', 'group' => 'MOD_QUICKICON_MAINTENANCE',
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="system" method="upgrade"> <extension type="plugin" group="system" method="upgrade">
<name>plg_system_mokobackup</name> <name>plg_system_mokobackup</name>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="task" method="upgrade"> <extension type="plugin" group="task" method="upgrade">
<name>plg_task_mokobackup</name> <name>plg_task_mokobackup</name>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -8,7 +8,7 @@
--> -->
<extension type="plugin" group="webservices" method="upgrade"> <extension type="plugin" group="webservices" method="upgrade">
<name>plg_webservices_mokobackup</name> <name>plg_webservices_mokobackup</name>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
+4 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade"> <extension type="package" method="upgrade">
<name>Package - MokoJoomBackup</name> <name>Package - MokoJoomBackup</name>
<packagename>mokobackup</packagename> <packagename>mokobackup</packagename>
<version>01.00.00</version> <version>01.01.07-dev</version>
<creationDate>2026-06-02</creationDate> <creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author> <author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail> <authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -25,6 +25,9 @@
<file type="plugin" id="mokobackup" group="task">plg_task_mokobackup.zip</file> <file type="plugin" id="mokobackup" group="task">plg_task_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="quickicon">plg_quickicon_mokobackup.zip</file> <file type="plugin" id="mokobackup" group="quickicon">plg_quickicon_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="webservices">plg_webservices_mokobackup.zip</file> <file type="plugin" id="mokobackup" group="webservices">plg_webservices_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="console">plg_console_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="content">plg_content_mokobackup.zip</file>
<file type="plugin" id="mokobackup" group="actionlog">plg_actionlog_mokobackup.zip</file>
</files> </files>
<languages> <languages>
+83
View File
@@ -12,6 +12,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\Factory; use Joomla\CMS\Factory;
use Joomla\CMS\Installer\InstallerAdapter; use Joomla\CMS\Installer\InstallerAdapter;
use Joomla\CMS\Language\Text; use Joomla\CMS\Language\Text;
use Joomla\CMS\Router\Route;
class Pkg_MokoBackupInstallerScript class Pkg_MokoBackupInstallerScript
{ {
@@ -107,6 +108,39 @@ class Pkg_MokoBackupInstallerScript
$db->setQuery($query); $db->setQuery($query);
$db->execute(); $db->execute();
// Enable the console plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('console'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Enable the content plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('content'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Enable the actionlog plugin automatically
$query = $db->getQuery(true)
->update($db->quoteName('#__extensions'))
->set($db->quoteName('enabled') . ' = 1')
->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
->where($db->quoteName('folder') . ' = ' . $db->quote('actionlog'))
->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup'));
$db->setQuery($query);
$db->execute();
// Create default backup directory // Create default backup directory
$backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups'; $backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups';
@@ -118,5 +152,54 @@ class Pkg_MokoBackupInstallerScript
file_put_contents($backupDir . '/index.html', '<!DOCTYPE html><title></title>'); file_put_contents($backupDir . '/index.html', '<!DOCTYPE html><title></title>');
} }
} }
// Show update site link after install or update
$this->showUpdateSiteNotice();
}
/**
* Show an info message linking directly to the update site record
* so the user can configure their download key.
*
* @return void
*/
private function showUpdateSiteNotice(): void
{
try {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName('us.update_site_id'))
->from($db->quoteName('#__update_sites', 'us'))
->join(
'INNER',
$db->quoteName('#__update_sites_extensions', 'use')
. ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id')
)
->join(
'INNER',
$db->quoteName('#__extensions', 'e')
. ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id')
)
->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokobackup'))
->where($db->quoteName('e.type') . ' = ' . $db->quote('package'))
->setLimit(1);
$db->setQuery($query);
$updateSiteId = (int) $db->loadResult();
if ($updateSiteId > 0) {
$editUrl = Route::_(
'index.php?option=com_installer&view=updatesites&task=updatesite.edit&id=' . $updateSiteId
);
Factory::getApplication()->enqueueMessage(
Text::sprintf('PKG_MOKOBACKUP_POSTINSTALL_UPDATE_SITE', $editUrl),
'info'
);
}
} catch (\Throwable $e) {
// Non-critical — silently ignore
}
} }
} }