From b82c1f8a244418be5e1de7e3f136eb99b6e8190e Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 13:47:36 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20MokoJoomBackup=20package=20?= =?UTF-8?q?=E2=80=94=20Akeeba=20Backup=20Pro=20replacement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-site backup and restore for Joomla with three sub-extensions: - com_mokobackup: Admin component with backup engine, profiles, and records - plg_system_mokobackup: Auto-cleanup of expired backups - plg_webservices_mokobackup: REST API wire-compatible with mcp_mokobackup Backup engine supports full/database/files modes with step-based execution, file/directory/table exclusion filters, and CLI script for cron use. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .editorconfig | 41 + .gitattributes | 62 ++ .gitignore | 203 +++++ .gitmessage | 9 + .mokogitea/manifest.xml | 21 + .mokogitea/workflows/ci-joomla.yml | 486 +++++++++++ .mokogitea/workflows/cleanup.yml | 87 ++ .mokogitea/workflows/gitleaks.yml | 96 +++ .mokogitea/workflows/notify.yml | 70 ++ .mokogitea/workflows/pr-check.yml | 219 +++++ .mokogitea/workflows/repo-health.yml | 767 ++++++++++++++++++ .mokogitea/workflows/security-audit.yml | 98 +++ CHANGELOG.md | 17 + CLAUDE.md | 79 ++ CONTRIBUTING.md | 34 + LICENSE | 22 + Makefile | 203 +++++ README.md | 55 ++ composer.json | 25 + phpstan.neon | 32 + src/index.html | 1 + src/language/en-GB/index.html | 1 + src/language/en-GB/pkg_mokobackup.sys.ini | 9 + src/language/en-US/index.html | 1 + src/language/en-US/pkg_mokobackup.sys.ini | 9 + src/language/index.html | 1 + src/packages/com_mokobackup/api/index.html | 1 + .../api/src/Controller/BackupsController.php | 100 +++ .../api/src/Controller/index.html | 1 + .../api/src/View/Backups/JsonapiView.php | 53 ++ .../api/src/View/Backups/index.html | 1 + .../com_mokobackup/api/src/View/index.html | 1 + .../com_mokobackup/api/src/index.html | 1 + src/packages/com_mokobackup/cli/index.html | 1 + .../com_mokobackup/cli/mokobackup.php | 68 ++ src/packages/com_mokobackup/forms/backup.xml | 15 + .../com_mokobackup/forms/filter_backups.xml | 47 ++ .../com_mokobackup/forms/filter_profiles.xml | 44 + src/packages/com_mokobackup/forms/index.html | 1 + src/packages/com_mokobackup/forms/profile.xml | 74 ++ src/packages/com_mokobackup/index.html | 1 + .../language/en-GB/com_mokobackup.ini | 91 +++ .../language/en-GB/com_mokobackup.sys.ini | 10 + .../com_mokobackup/language/en-GB/index.html | 1 + .../language/en-US/com_mokobackup.ini | 15 + .../language/en-US/com_mokobackup.sys.ini | 10 + .../com_mokobackup/language/en-US/index.html | 1 + .../com_mokobackup/language/index.html | 1 + src/packages/com_mokobackup/mokobackup.xml | 89 ++ .../com_mokobackup/services/index.html | 1 + .../com_mokobackup/services/provider.php | 40 + src/packages/com_mokobackup/sql/index.html | 1 + .../com_mokobackup/sql/install.mysql.sql | 47 ++ .../com_mokobackup/sql/mysql/index.html | 1 + .../com_mokobackup/sql/uninstall.mysql.sql | 2 + .../com_mokobackup/sql/updates/index.html | 1 + .../sql/updates/mysql/01.00.00.sql | 1 + .../sql/updates/mysql/index.html | 1 + .../src/Controller/BackupController.php | 20 + .../src/Controller/BackupsController.php | 82 ++ .../src/Controller/DisplayController.php | 20 + .../src/Controller/ProfileController.php | 20 + .../src/Controller/ProfilesController.php | 25 + .../com_mokobackup/src/Controller/index.html | 1 + .../src/Engine/BackupEngine.php | 187 +++++ .../src/Engine/DatabaseDumper.php | 155 ++++ .../com_mokobackup/src/Engine/FileScanner.php | 110 +++ .../com_mokobackup/src/Engine/index.html | 1 + .../src/Extension/MokoBackupComponent.php | 19 + .../com_mokobackup/src/Extension/index.html | 1 + .../com_mokobackup/src/Model/BackupModel.php | 46 ++ .../com_mokobackup/src/Model/BackupsModel.php | 83 ++ .../com_mokobackup/src/Model/ProfileModel.php | 46 ++ .../src/Model/ProfilesModel.php | 67 ++ .../com_mokobackup/src/Model/index.html | 1 + .../com_mokobackup/src/Table/BackupTable.php | 49 ++ .../com_mokobackup/src/Table/ProfileTable.php | 47 ++ .../com_mokobackup/src/Table/index.html | 1 + .../src/View/Backup/HtmlView.php | 39 + .../com_mokobackup/src/View/Backup/index.html | 1 + .../src/View/Backups/HtmlView.php | 47 ++ .../src/View/Backups/index.html | 1 + .../src/View/Profile/HtmlView.php | 44 + .../src/View/Profile/index.html | 1 + .../src/View/Profiles/HtmlView.php | 48 ++ .../src/View/Profiles/index.html | 1 + .../com_mokobackup/src/View/index.html | 1 + src/packages/com_mokobackup/src/index.html | 1 + .../com_mokobackup/tmpl/backup/default.php | 62 ++ .../com_mokobackup/tmpl/backup/index.html | 1 + .../com_mokobackup/tmpl/backups/default.php | 131 +++ .../com_mokobackup/tmpl/backups/index.html | 1 + src/packages/com_mokobackup/tmpl/index.html | 1 + .../com_mokobackup/tmpl/profile/edit.php | 50 ++ .../com_mokobackup/tmpl/profile/index.html | 1 + .../com_mokobackup/tmpl/profiles/default.php | 93 +++ .../com_mokobackup/tmpl/profiles/index.html | 1 + src/packages/index.html | 1 + src/packages/plg_system_mokobackup/index.html | 1 + .../language/en-GB/index.html | 1 + .../language/en-GB/plg_system_mokobackup.ini | 9 + .../en-GB/plg_system_mokobackup.sys.ini | 3 + .../language/en-US/index.html | 1 + .../language/en-US/plg_system_mokobackup.ini | 9 + .../en-US/plg_system_mokobackup.sys.ini | 3 + .../plg_system_mokobackup/language/index.html | 1 + .../plg_system_mokobackup/mokobackup.php | 11 + .../plg_system_mokobackup/mokobackup.xml | 68 ++ .../plg_system_mokobackup/services/index.html | 1 + .../services/provider.php | 37 + .../src/Extension/MokoBackup.php | 124 +++ .../src/Extension/index.html | 1 + .../plg_system_mokobackup/src/index.html | 1 + .../plg_webservices_mokobackup/index.html | 1 + .../language/en-GB/index.html | 1 + .../en-GB/plg_webservices_mokobackup.ini | 3 + .../en-GB/plg_webservices_mokobackup.sys.ini | 3 + .../language/en-US/index.html | 1 + .../en-US/plg_webservices_mokobackup.ini | 3 + .../en-US/plg_webservices_mokobackup.sys.ini | 3 + .../language/index.html | 1 + .../plg_webservices_mokobackup/mokobackup.php | 11 + .../plg_webservices_mokobackup/mokobackup.xml | 32 + .../services/index.html | 1 + .../services/provider.php | 37 + .../src/Extension/MokoBackupWebServices.php | 98 +++ .../src/Extension/index.html | 1 + .../plg_webservices_mokobackup/src/index.html | 1 + src/pkg_mokobackup.xml | 35 + src/script.php | 100 +++ updates.xml | 15 + 131 files changed, 5495 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitmessage create mode 100644 .mokogitea/manifest.xml create mode 100644 .mokogitea/workflows/ci-joomla.yml create mode 100644 .mokogitea/workflows/cleanup.yml create mode 100644 .mokogitea/workflows/gitleaks.yml create mode 100644 .mokogitea/workflows/notify.yml create mode 100644 .mokogitea/workflows/pr-check.yml create mode 100644 .mokogitea/workflows/repo-health.yml create mode 100644 .mokogitea/workflows/security-audit.yml create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 src/index.html create mode 100644 src/language/en-GB/index.html create mode 100644 src/language/en-GB/pkg_mokobackup.sys.ini create mode 100644 src/language/en-US/index.html create mode 100644 src/language/en-US/pkg_mokobackup.sys.ini create mode 100644 src/language/index.html create mode 100644 src/packages/com_mokobackup/api/index.html create mode 100644 src/packages/com_mokobackup/api/src/Controller/BackupsController.php create mode 100644 src/packages/com_mokobackup/api/src/Controller/index.html create mode 100644 src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php create mode 100644 src/packages/com_mokobackup/api/src/View/Backups/index.html create mode 100644 src/packages/com_mokobackup/api/src/View/index.html create mode 100644 src/packages/com_mokobackup/api/src/index.html create mode 100644 src/packages/com_mokobackup/cli/index.html create mode 100644 src/packages/com_mokobackup/cli/mokobackup.php create mode 100644 src/packages/com_mokobackup/forms/backup.xml create mode 100644 src/packages/com_mokobackup/forms/filter_backups.xml create mode 100644 src/packages/com_mokobackup/forms/filter_profiles.xml create mode 100644 src/packages/com_mokobackup/forms/index.html create mode 100644 src/packages/com_mokobackup/forms/profile.xml create mode 100644 src/packages/com_mokobackup/index.html create mode 100644 src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini create mode 100644 src/packages/com_mokobackup/language/en-GB/com_mokobackup.sys.ini create mode 100644 src/packages/com_mokobackup/language/en-GB/index.html create mode 100644 src/packages/com_mokobackup/language/en-US/com_mokobackup.ini create mode 100644 src/packages/com_mokobackup/language/en-US/com_mokobackup.sys.ini create mode 100644 src/packages/com_mokobackup/language/en-US/index.html create mode 100644 src/packages/com_mokobackup/language/index.html create mode 100644 src/packages/com_mokobackup/mokobackup.xml create mode 100644 src/packages/com_mokobackup/services/index.html create mode 100644 src/packages/com_mokobackup/services/provider.php create mode 100644 src/packages/com_mokobackup/sql/index.html create mode 100644 src/packages/com_mokobackup/sql/install.mysql.sql create mode 100644 src/packages/com_mokobackup/sql/mysql/index.html create mode 100644 src/packages/com_mokobackup/sql/uninstall.mysql.sql create mode 100644 src/packages/com_mokobackup/sql/updates/index.html create mode 100644 src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql create mode 100644 src/packages/com_mokobackup/sql/updates/mysql/index.html create mode 100644 src/packages/com_mokobackup/src/Controller/BackupController.php create mode 100644 src/packages/com_mokobackup/src/Controller/BackupsController.php create mode 100644 src/packages/com_mokobackup/src/Controller/DisplayController.php create mode 100644 src/packages/com_mokobackup/src/Controller/ProfileController.php create mode 100644 src/packages/com_mokobackup/src/Controller/ProfilesController.php create mode 100644 src/packages/com_mokobackup/src/Controller/index.html create mode 100644 src/packages/com_mokobackup/src/Engine/BackupEngine.php create mode 100644 src/packages/com_mokobackup/src/Engine/DatabaseDumper.php create mode 100644 src/packages/com_mokobackup/src/Engine/FileScanner.php create mode 100644 src/packages/com_mokobackup/src/Engine/index.html create mode 100644 src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php create mode 100644 src/packages/com_mokobackup/src/Extension/index.html create mode 100644 src/packages/com_mokobackup/src/Model/BackupModel.php create mode 100644 src/packages/com_mokobackup/src/Model/BackupsModel.php create mode 100644 src/packages/com_mokobackup/src/Model/ProfileModel.php create mode 100644 src/packages/com_mokobackup/src/Model/ProfilesModel.php create mode 100644 src/packages/com_mokobackup/src/Model/index.html create mode 100644 src/packages/com_mokobackup/src/Table/BackupTable.php create mode 100644 src/packages/com_mokobackup/src/Table/ProfileTable.php create mode 100644 src/packages/com_mokobackup/src/Table/index.html create mode 100644 src/packages/com_mokobackup/src/View/Backup/HtmlView.php create mode 100644 src/packages/com_mokobackup/src/View/Backup/index.html create mode 100644 src/packages/com_mokobackup/src/View/Backups/HtmlView.php create mode 100644 src/packages/com_mokobackup/src/View/Backups/index.html create mode 100644 src/packages/com_mokobackup/src/View/Profile/HtmlView.php create mode 100644 src/packages/com_mokobackup/src/View/Profile/index.html create mode 100644 src/packages/com_mokobackup/src/View/Profiles/HtmlView.php create mode 100644 src/packages/com_mokobackup/src/View/Profiles/index.html create mode 100644 src/packages/com_mokobackup/src/View/index.html create mode 100644 src/packages/com_mokobackup/src/index.html create mode 100644 src/packages/com_mokobackup/tmpl/backup/default.php create mode 100644 src/packages/com_mokobackup/tmpl/backup/index.html create mode 100644 src/packages/com_mokobackup/tmpl/backups/default.php create mode 100644 src/packages/com_mokobackup/tmpl/backups/index.html create mode 100644 src/packages/com_mokobackup/tmpl/index.html create mode 100644 src/packages/com_mokobackup/tmpl/profile/edit.php create mode 100644 src/packages/com_mokobackup/tmpl/profile/index.html create mode 100644 src/packages/com_mokobackup/tmpl/profiles/default.php create mode 100644 src/packages/com_mokobackup/tmpl/profiles/index.html create mode 100644 src/packages/index.html create mode 100644 src/packages/plg_system_mokobackup/index.html create mode 100644 src/packages/plg_system_mokobackup/language/en-GB/index.html create mode 100644 src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.ini create mode 100644 src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.sys.ini create mode 100644 src/packages/plg_system_mokobackup/language/en-US/index.html create mode 100644 src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.ini create mode 100644 src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.sys.ini create mode 100644 src/packages/plg_system_mokobackup/language/index.html create mode 100644 src/packages/plg_system_mokobackup/mokobackup.php create mode 100644 src/packages/plg_system_mokobackup/mokobackup.xml create mode 100644 src/packages/plg_system_mokobackup/services/index.html create mode 100644 src/packages/plg_system_mokobackup/services/provider.php create mode 100644 src/packages/plg_system_mokobackup/src/Extension/MokoBackup.php create mode 100644 src/packages/plg_system_mokobackup/src/Extension/index.html create mode 100644 src/packages/plg_system_mokobackup/src/index.html create mode 100644 src/packages/plg_webservices_mokobackup/index.html create mode 100644 src/packages/plg_webservices_mokobackup/language/en-GB/index.html create mode 100644 src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.ini create mode 100644 src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.sys.ini create mode 100644 src/packages/plg_webservices_mokobackup/language/en-US/index.html create mode 100644 src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.ini create mode 100644 src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.sys.ini create mode 100644 src/packages/plg_webservices_mokobackup/language/index.html create mode 100644 src/packages/plg_webservices_mokobackup/mokobackup.php create mode 100644 src/packages/plg_webservices_mokobackup/mokobackup.xml create mode 100644 src/packages/plg_webservices_mokobackup/services/index.html create mode 100644 src/packages/plg_webservices_mokobackup/services/provider.php create mode 100644 src/packages/plg_webservices_mokobackup/src/Extension/MokoBackupWebServices.php create mode 100644 src/packages/plg_webservices_mokobackup/src/Extension/index.html create mode 100644 src/packages/plg_webservices_mokobackup/src/index.html create mode 100644 src/pkg_mokobackup.xml create mode 100644 src/script.php create mode 100644 updates.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e868be9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +# EditorConfig helps maintain consistent coding styles across different editors and IDEs +# https://editorconfig.org/ + +root = true + +# Default settings — Tabs preferred, width = 2 spaces +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +tab_width = 2 + +# PowerShell scripts — tabs, 2-space visual width +[*.ps1] +indent_style = tab +tab_width = 2 +end_of_line = crlf + +# Markdown files — keep trailing whitespace for line breaks +[*.md] +trim_trailing_whitespace = false + +# JSON / YAML files — tabs, 2-space visual width +[*.{json,yml,yaml}] +indent_style = tab +tab_width = 2 + +# Makefiles — always tabs, default width +[Makefile] +indent_style = tab +tab_width = 2 + +# Windows batch scripts — keep CRLF endings +[*.{bat,cmd}] +end_of_line = crlf + +# Shell scripts — ensure LF endings +[*.sh] +end_of_line = lf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..998448a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,62 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# PHP files +*.php text eol=lf + +# XML manifests +*.xml text eol=lf + +# Language files +*.ini text eol=lf + +# SQL files +*.sql text eol=lf + +# Shell scripts +*.sh text eol=lf + +# Markdown +*.md text eol=lf + +# YAML +*.yml text eol=lf +*.yaml text eol=lf + +# CSS/JS +*.css text eol=lf +*.js text eol=lf + +# JSON +*.json text eol=lf + +# Windows scripts +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary files +*.zip binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary + +# Export ignore (not included in archives) +.mokogitea/ export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.gitmessage export-ignore +CLAUDE.md export-ignore +CONTRIBUTING.md export-ignore +CODE_OF_CONDUCT.md export-ignore +Makefile export-ignore +composer.json export-ignore +phpstan.neon export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4abd225 --- /dev/null +++ b/.gitignore @@ -0,0 +1,203 @@ +# ============================================================ +# Local task tracking (not version controlled) +# ============================================================ +TODO.md + +# ============================================================ +# Environment and secrets +# ============================================================ +.env +.env.local +.env.*.local +*.local.php +*.secret.php +configuration.php +configuration.*.php +configuration.local.php +conf/conf.php +conf/conf*.php +secrets/ +*.secrets.* + +# ============================================================ +# Logs, dumps and databases +# ============================================================ +*.db +*.db-journal +*.dump +*.log +*.pid +*.seed + +# ============================================================ +# OS / Editor / IDE cruft +# ============================================================ +.DS_Store +Thumbs.db +desktop.ini +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +$RECYCLE.BIN/ +System Volume Information/ +*.lnk +Icon? +.idea/ +.settings/ +.claude/ +.vscode/* +!.vscode/tasks.json +!.vscode/settings.json.example +!.vscode/extensions.json +*.code-workspace +*.sublime* +.project +.buildpath +.classpath +*.bak +*.swp +*.swo +*.tmp +*.old +*.orig + +# ============================================================ +# Dev scripts and scratch +# ============================================================ +TODO.md +todo* +*ffs* + +# ============================================================ +# SFTP / sync tools +# ============================================================ +sftp-config*.json +sftp-config.json.template +sftp-settings.json + +# ============================================================ +# Sublime SFTP / FTP sync +# ============================================================ +*.sublime-project +*.sublime-workspace +*.sublime-settings +.libsass.json +*.ffs* + +# ============================================================ +# Replit / cloud IDE +# ============================================================ +.replit +replit.md + +# ============================================================ +# Archives / release artifacts +# ============================================================ +*.7z +*.rar +*.tar +*.tar.gz +*.tgz +*.zip +artifacts/ +release/ +releases/ + +# ============================================================ +# Build outputs and site generators +# ============================================================ +.mkdocs-build/ +.cache/ +.parcel-cache/ +build/ +dist/ +out/ +/site/ +*.map +*.css.map +*.js.map +*.tsbuildinfo + +# ============================================================ +# CI / test artifacts +# ============================================================ +.coverage +.coverage.* +coverage/ +coverage.xml +htmlcov/ +junit.xml +reports/ +test-results/ +tests/_output/ +.github/local/ +.github/workflows/*.log + +# ============================================================ +# Node / JavaScript +# ============================================================ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ +.yarn/ +.npmrc +.eslintcache +package-lock.json + +# ============================================================ +# PHP / Composer tooling +# ============================================================ +vendor/ +!src/media/vendor/ +composer.lock +*.phar +codeception.phar +.phpunit.result.cache +.php_cs.cache +.php-cs-fixer.cache +.phpstan.cache +.phplint-cache +phpmd-cache/ +.psalm/ +.rector/ + +# ============================================================ +# Python +# ============================================================ +__pycache__/ +*.py[cod] +*.pyc +*$py.class +*.so +.Python +.eggs/ +*.egg +*.egg-info/ +.installed.cfg +MANIFEST +develop-eggs/ +downloads/ +eggs/ +parts/ +sdist/ +var/ +wheels/ +ENV/ +env/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyright/ +.tox/ +.nox/ +*.cover +*.coverage +hypothesis/ + +profile.ps1 +.mcp.json diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..70f2036 --- /dev/null +++ b/.gitmessage @@ -0,0 +1,9 @@ +# (): +# types: build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test +# subject: imperative, lower-case, no trailing period + +# Body: what and why + +# BREAKING CHANGE: +# Closes: #123 +# Signed-off-by: diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml new file mode 100644 index 0000000..bf69601 --- /dev/null +++ b/.mokogitea/manifest.xml @@ -0,0 +1,21 @@ + + + + MokoJoomBackup + Package - MokoJoomBackup + MokoConsulting + Full-site backup and restore for Joomla — database, files, and configuration + 01.00.00-dev + GNU General Public License v3 + + + joomla + 05.00.00 + https://git.mokoconsulting.tech/MokoConsulting/moko-platform + + + PHP + joomla-extension + src/ + + diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml new file mode 100644 index 0000000..f679e86 --- /dev/null +++ b/.mokogitea/workflows/ci-joomla.yml @@ -0,0 +1,486 @@ +# Copyright (C) 2026 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow.Template +# INGROUP: MokoStandards.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API +# PATH: /templates/workflows/joomla/ci-joomla.yml.template +# VERSION: 04.06.00 +# BRIEF: CI workflow for Joomla extensions — lint, validate, test + +name: "Joomla: Extension CI" + +on: + pull_request: + branches: + - main + - 'dev/**' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + lint-and-validate: + name: Lint & Validate + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP + run: | + php -v && composer --version + + - name: Clone MokoStandards + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + MOKO_CLONE_HOST: ${{ secrets.MOKOGITEA_TOKEN && 'git.mokoconsulting.tech/MokoConsulting' || 'github.com/mokoconsulting-tech' }} + run: | + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/MokoStandards-API.git" \ + /tmp/mokostandards-api + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install \ + --no-interaction \ + --prefer-dist \ + --optimize-autoloader + else + echo "No composer.json found — skipping dependency install" + fi + + - name: PHP syntax check + run: | + ERRORS=0 + for DIR in src/ htdocs/; do + if [ -d "$DIR" ]; then + FOUND=1 + while IFS= read -r -d '' FILE; do + OUTPUT=$(php -l "$FILE" 2>&1) + if echo "$OUTPUT" | grep -q "Parse error"; then + echo "::error file=${FILE}::${OUTPUT}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find "$DIR" -name "*.php" -print0) + fi + done + echo "### PHP Syntax Check" >> $GITHUB_STEP_SUMMARY + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} syntax error(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "All PHP files passed syntax check." >> $GITHUB_STEP_SUMMARY + fi + + - name: XML manifest validation + run: | + echo "### XML Manifest Validation" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Find the extension manifest (XML with /dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No Joomla extension manifest found (XML file with \`> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest found: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY + + # Validate well-formed XML + php -r " + \$xml = @simplexml_load_file('$MANIFEST'); + if (\$xml === false) { + echo 'INVALID'; + exit(1); + } + echo 'VALID'; + " > /tmp/xml_result 2>&1 + XML_RESULT=$(cat /tmp/xml_result) + if [ "$XML_RESULT" != "VALID" ]; then + echo "Manifest is not well-formed XML." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest is well-formed XML." >> $GITHUB_STEP_SUMMARY + fi + + # Check required tags + REQUIRED_TAGS="name version author" + # namespace is only required for non-package extensions + if ! grep -q 'type="package"' "$MANIFEST" 2>/dev/null; then + REQUIRED_TAGS="$REQUIRED_TAGS namespace" + fi + for TAG in $REQUIRED_TAGS; do + if ! grep -q "<${TAG}>" "$MANIFEST" 2>/dev/null; then + echo "Missing required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Found required tag: \`<${TAG}>\`" >> $GITHUB_STEP_SUMMARY + fi + done + fi + + if [ "${ERRORS}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${ERRORS} manifest issue(s) found.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Manifest validation passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check language files referenced in manifest + run: | + echo "### Language File Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -n "$MANIFEST" ]; then + # Extract language file references from manifest + LANG_FILES=$(grep -oP 'language\s+tag="[^"]*"[^>]*>\K[^<]+' "$MANIFEST" 2>/dev/null || true) + if [ -z "$LANG_FILES" ]; then + echo "No language file references found in manifest — skipping." >> $GITHUB_STEP_SUMMARY + else + while IFS= read -r LANG_FILE; do + LANG_FILE=$(echo "$LANG_FILE" | xargs) + if [ -z "$LANG_FILE" ]; then + continue + fi + # Check in common locations + FOUND=0 + for BASE in "." "src" "htdocs"; do + if [ -f "${BASE}/${LANG_FILE}" ]; then + FOUND=1 + break + fi + done + if [ "$FOUND" -eq 0 ]; then + echo "Missing language file: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Language file present: \`${LANG_FILE}\`" >> $GITHUB_STEP_SUMMARY + fi + done <<< "$LANG_FILES" + fi + else + echo "No manifest found — skipping language check." >> $GITHUB_STEP_SUMMARY + fi + + if [ "${ERRORS}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${ERRORS} missing language file(s).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Language file check passed.**" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check en-GB and en-US language directories exist + run: | + echo "### Language Directory Check" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + for DIR in src/ htdocs/; do + [ -d "$DIR" ] || continue + # Find all language directories + while IFS= read -r -d '' LANG_DIR; do + HAS_GB=false + HAS_US=false + [ -d "${LANG_DIR}/en-GB" ] && HAS_GB=true + [ -d "${LANG_DIR}/en-US" ] && HAS_US=true + if [ "$HAS_GB" = false ]; then + echo "Missing \`en-GB\` in: \`${LANG_DIR}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + if [ "$HAS_US" = false ]; then + echo "Missing \`en-US\` in: \`${LANG_DIR}\`" >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + done < <(find "$DIR" -type d -name "language" -print0) + done + + if [ "${ERRORS}" -gt 0 ]; then + echo "**${ERRORS} missing language director(ies).**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "All language directories have en-GB and en-US." >> $GITHUB_STEP_SUMMARY + fi + + - name: Check index.html files in directories + run: | + echo "### Index.html Check" >> $GITHUB_STEP_SUMMARY + MISSING=0 + CHECKED=0 + + for DIR in src/ htdocs/; do + if [ -d "$DIR" ]; then + while IFS= read -r -d '' SUBDIR; do + CHECKED=$((CHECKED + 1)) + if [ ! -f "${SUBDIR}/index.html" ]; then + echo "Missing index.html in: \`${SUBDIR}\`" >> $GITHUB_STEP_SUMMARY + MISSING=$((MISSING + 1)) + fi + done < <(find "$DIR" -type d -print0) + fi + done + + if [ "${CHECKED}" -eq 0 ]; then + echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY + elif [ "${MISSING}" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**${MISSING} director(ies) missing index.html out of ${CHECKED} checked.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "All ${CHECKED} directories contain index.html." >> $GITHUB_STEP_SUMMARY + fi + + release-readiness: + name: Release Readiness Check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.base_ref == 'main' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Validate release readiness + run: | + echo "## Release Readiness" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + ERRORS=0 + + # Extract version from README.md (supports both FILE INFORMATION block and HTML comment format) + README_VERSION=$(sed -n 's/.*VERSION:\s*\([0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\).*/\1/p' README.md | head -1) + if [ -z "$README_VERSION" ]; then + echo "No VERSION found in README.md FILE INFORMATION block." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "README version: \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Find the extension manifest + MANIFEST="" + for XML_FILE in $(find . -maxdepth 2 -name "*.xml" -not -path "./.git/*" -not -path "./vendor/*"); do + if grep -q "/dev/null; then + MANIFEST="$XML_FILE" + break + fi + done + + if [ -z "$MANIFEST" ]; then + echo "No Joomla extension manifest found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY + + # Check matches README VERSION + MANIFEST_VERSION=$(sed -n 's/.*\([^<]*\)<\/version>.*/\1/p' "$MANIFEST" | head -1) + if [ -z "$MANIFEST_VERSION" ]; then + echo "No \`\` tag in manifest." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + elif [ -n "$README_VERSION" ] && [ "$MANIFEST_VERSION" != "$README_VERSION" ]; then + echo "Manifest version \`${MANIFEST_VERSION}\` does not match README \`${README_VERSION}\`." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Manifest version: \`${MANIFEST_VERSION}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Check extension type, element, client attributes + EXT_TYPE=$(grep -oP ']*\btype="\K[^"]+' "$MANIFEST" | head -1) + if [ -z "$EXT_TYPE" ]; then + echo "Missing \`type\` attribute on \`\` tag." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + else + echo "Extension type: \`${EXT_TYPE}\`" >> $GITHUB_STEP_SUMMARY + fi + + # Element check (component/module/plugin name) + HAS_ELEMENT=$(grep -cP '<(element|name)>' "$MANIFEST" 2>/dev/null || echo "0") + if [ "$HAS_ELEMENT" -eq 0 ]; then + echo "Missing \`\` or \`\` in manifest." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # Client attribute for site/admin modules and plugins + if echo "$EXT_TYPE" | grep -qP "^(module|plugin)$"; then + HAS_CLIENT=$(grep -cP ']*\bclient=' "$MANIFEST" 2>/dev/null || echo "0") + if [ "$HAS_CLIENT" -eq 0 ]; then + echo "Missing \`client\` attribute for ${EXT_TYPE} extension." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + fi + fi + + # Check updates.xml exists + if [ -f "updates.xml" ] || [ -f "updates.xml" ]; then + echo "Update XML present." >> $GITHUB_STEP_SUMMARY + else + echo "No updates.xml found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + # Check CHANGELOG.md exists + if [ -f "CHANGELOG.md" ]; then + echo "CHANGELOG.md present." >> $GITHUB_STEP_SUMMARY + else + echo "No CHANGELOG.md found." >> $GITHUB_STEP_SUMMARY + ERRORS=$((ERRORS + 1)) + fi + + echo "" >> $GITHUB_STEP_SUMMARY + if [ $ERRORS -gt 0 ]; then + echo "**${ERRORS} issue(s) must be resolved before release.**" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "**Extension is ready for release.**" >> $GITHUB_STEP_SUMMARY + fi + + test: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + needs: lint-and-validate + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3'] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP ${{ matrix.php }} + run: | + php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install \ + --no-interaction \ + --prefer-dist \ + --optimize-autoloader + else + echo "No composer.json found — skipping dependency install" + fi + + - name: Run tests + run: | + echo "### Test Results (PHP ${{ matrix.php }})" >> $GITHUB_STEP_SUMMARY + if [ -f "phpunit.xml" ] || [ -f "phpunit.xml.dist" ]; then + vendor/bin/phpunit --testdox 2>&1 | tee /tmp/test-output.log + EXIT=${PIPESTATUS[0]} + if [ $EXIT -eq 0 ]; then + echo "All tests passed." >> $GITHUB_STEP_SUMMARY + else + echo "Test failures detected — see log." >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/test-output.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + exit $EXIT + else + echo "No phpunit.xml found — skipping tests." >> $GITHUB_STEP_SUMMARY + fi + + static-analysis: + name: PHPStan Analysis + runs-on: ubuntu-latest + needs: lint-and-validate + continue-on-error: true + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup PHP + run: php -v && composer --version + + - name: Install dependencies + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN || github.token }}"}}' + run: | + if [ -f "composer.json" ]; then + composer install --no-interaction --prefer-dist --optimize-autoloader + fi + + - name: Install PHPStan + run: | + if ! command -v vendor/bin/phpstan &> /dev/null; then + composer require --dev phpstan/phpstan --no-interaction 2>/dev/null || \ + composer global require phpstan/phpstan --no-interaction + fi + + - name: Run PHPStan + run: | + echo "### PHPStan Static Analysis" >> $GITHUB_STEP_SUMMARY + PHPSTAN="vendor/bin/phpstan" + if [ ! -f "$PHPSTAN" ]; then + PHPSTAN=$(composer global config bin-dir --absolute 2>/dev/null)/phpstan + fi + + # Determine source directory + SRC_DIR="" + for DIR in src/ htdocs/ lib/; do + if [ -d "$DIR" ]; then + SRC_DIR="$DIR" + break + fi + done + + if [ -z "$SRC_DIR" ]; then + echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Use repo phpstan.neon if present, otherwise use baseline config + ARGS="analyse ${SRC_DIR} --memory-limit=512M --no-progress --error-format=table" + if [ -f "phpstan.neon" ] || [ -f "phpstan.neon.dist" ]; then + echo "Using project PHPStan config." >> $GITHUB_STEP_SUMMARY + else + ARGS="$ARGS --level=3" + echo "No phpstan.neon found — using level 3 (type inference)." >> $GITHUB_STEP_SUMMARY + fi + + $PHPSTAN $ARGS 2>&1 | tee /tmp/phpstan-output.txt + EXIT=${PIPESTATUS[0]} + + if [ $EXIT -eq 0 ]; then + echo "**No errors found.**" >> $GITHUB_STEP_SUMMARY + else + ERRORS=$(grep -c "ERROR" /tmp/phpstan-output.txt 2>/dev/null || echo "some") + echo "**${ERRORS} error(s) found.** Review output above." >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -30 /tmp/phpstan-output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + exit $EXIT diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml new file mode 100644 index 0000000..29ca4d4 --- /dev/null +++ b/.mokogitea/workflows/cleanup.yml @@ -0,0 +1,87 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Maintenance +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.gitea/workflows/cleanup.yml +# VERSION: 01.00.00 +# BRIEF: Scheduled cleanup — delete merged branches and old workflow runs + +name: "Universal: Repository Cleanup" + +on: + schedule: + - cron: '0 3 * * 0' # Weekly on Sunday at 03:00 UTC + workflow_dispatch: + +permissions: + contents: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +jobs: + cleanup: + name: Clean Merged Branches + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.MOKOGITEA_TOKEN }} + + - name: Delete merged branches + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + echo "=== Merged Branch Cleanup ===" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + + # List branches via API + BRANCHES=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/branches?limit=50" | jq -r '.[].name') + + DELETED=0 + for BRANCH in $BRANCHES; do + # Skip protected branches + case "$BRANCH" in + main|master|develop|release/*|hotfix/*) continue ;; + esac + + # Check if branch is merged into main + if git merge-base --is-ancestor "origin/${BRANCH}" origin/main 2>/dev/null; then + echo " Deleting merged branch: ${BRANCH}" + curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/branches/${BRANCH}" 2>/dev/null || true + DELETED=$((DELETED + 1)) + fi + done + + echo "Deleted ${DELETED} merged branch(es)" + + - name: Clean old workflow runs + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + run: | + echo "=== Workflow Run Cleanup ===" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + CUTOFF=$(date -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -v-30d +%Y-%m-%dT%H:%M:%SZ) + + # Get old completed runs + RUNS=$(curl -sS -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/actions/runs?status=completed&limit=50" | \ + jq -r ".workflow_runs[] | select(.created_at < \"${CUTOFF}\") | .id" 2>/dev/null) + + DELETED=0 + for RUN_ID in $RUNS; do + curl -sS -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \ + "${API}/actions/runs/${RUN_ID}" 2>/dev/null || true + DELETED=$((DELETED + 1)) + done + + echo "Deleted ${DELETED} old workflow run(s)" diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml new file mode 100644 index 0000000..e0fdd1d --- /dev/null +++ b/.mokogitea/workflows/gitleaks.yml @@ -0,0 +1,96 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/gitleaks.yml.template +# VERSION: 01.00.00 +# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens +# +# +========================================================================+ +# | SECRET SCANNING | +# +========================================================================+ +# | | +# | Scans commits for leaked secrets using Gitleaks. | +# | | +# | - PR scan: only new commits in the PR | +# | - Scheduled: full repo scan weekly | +# | - Alerts via ntfy on findings | +# | | +# +========================================================================+ + +name: "Universal: Secret Scanning" + +on: + pull_request: + branches: + - main + - 'dev/**' + schedule: + - cron: '0 5 * * 1' # Weekly Monday 05:00 UTC + workflow_dispatch: + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }} + +jobs: + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Gitleaks + run: | + GITLEAKS_VERSION="8.21.2" + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - name: Scan for secrets + id: scan + run: | + echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY + ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json" + + if [ "${{ github.event_name }}" = "pull_request" ]; then + # Scan only PR commits + ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY + else + echo "Full repository scan" >> $GITHUB_STEP_SUMMARY + fi + + if gitleaks detect $ARGS 2>&1; then + echo "result=clean" >> "$GITHUB_OUTPUT" + echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY + else + echo "result=found" >> "$GITHUB_OUTPUT" + FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown") + echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: Notify on findings + if: failure() && steps.scan.outputs.result == 'found' + run: | + REPO="${{ github.event.repository.name }}" + curl -sS \ + -H "Title: ${REPO} — secrets detected in code" \ + -H "Tags: rotating_light,key" \ + -H "Priority: urgent" \ + -d "Gitleaks found potential secrets. Review and rotate credentials immediately." \ + "${NTFY_URL}/${NTFY_TOPIC}" || true diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml new file mode 100644 index 0000000..cde4541 --- /dev/null +++ b/.mokogitea/workflows/notify.yml @@ -0,0 +1,70 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Notifications +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.gitea/workflows/notify.yml +# VERSION: 01.00.00 +# BRIEF: Push notifications via ntfy on release success or workflow failure + +name: "Universal: Notifications" + +on: + workflow_run: + workflows: + - "Joomla Build & Release" + - "Joomla Extension CI" + - "Deploy" + types: + - completed + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }} + +jobs: + notify: + name: Send Notification + runs-on: ubuntu-latest + if: >- + github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure' + + steps: + - name: Notify on success (releases only) + if: >- + github.event.workflow_run.conclusion == 'success' && + contains(github.event.workflow_run.name, 'Release') + run: | + REPO="${{ github.event.repository.name }}" + WORKFLOW="${{ github.event.workflow_run.name }}" + URL="${{ github.event.workflow_run.html_url }}" + + curl -sS \ + -H "Title: ${REPO} released" \ + -H "Tags: white_check_mark,package" \ + -H "Priority: default" \ + -H "Click: ${URL}" \ + -d "${WORKFLOW} completed successfully." \ + "${NTFY_URL}/${NTFY_TOPIC}" + + - name: Notify on failure + if: github.event.workflow_run.conclusion == 'failure' + run: | + REPO="${{ github.event.repository.name }}" + WORKFLOW="${{ github.event.workflow_run.name }}" + URL="${{ github.event.workflow_run.html_url }}" + + curl -sS \ + -H "Title: ${REPO} workflow failed" \ + -H "Tags: x,warning" \ + -H "Priority: high" \ + -H "Click: ${URL}" \ + -d "${WORKFLOW} failed. Check the run for details." \ + "${NTFY_URL}/${NTFY_TOPIC}" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml new file mode 100644 index 0000000..39d5623 --- /dev/null +++ b/.mokogitea/workflows/pr-check.yml @@ -0,0 +1,219 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.CI +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/pr-check.yml.template +# VERSION: 05.00.00 +# BRIEF: PR gate — branch policy + code validation before merge + +name: "Universal: PR Check" + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + # ── Branch Policy ────────────────────────────────────────────────────── + branch-policy: + name: Branch Policy + runs-on: ubuntu-latest + steps: + - name: Check branch merge target + run: | + HEAD="${{ github.head_ref }}" + BASE="${{ github.base_ref }}" + + echo "PR: ${HEAD} → ${BASE}" + + ALLOWED=true + REASON="" + + case "$HEAD" in + feature/*|feat/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Feature branches must target 'dev', not '${BASE}'" + fi + ;; + fix/*|bugfix/*) + if [ "$BASE" != "dev" ]; then + ALLOWED=false + REASON="Fix branches must target 'dev', not '${BASE}'" + fi + ;; + patch/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then + ALLOWED=false + REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'" + fi + ;; + hotfix/*) + if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'" + fi + ;; + rc) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="RC branch can only merge into 'main', not '${BASE}'" + fi + ;; + dev) + if [ "$BASE" != "main" ]; then + ALLOWED=false + REASON="Dev branch can only merge into 'main', not '${BASE}'" + fi + ;; + esac + + if [ "$ALLOWED" = false ]; then + echo "::error::${REASON}" + echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "${REASON}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY + echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY + echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY + echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + echo "Branch policy: OK (${HEAD} → ${BASE})" + echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY + + # ── Code Validation ──────────────────────────────────────────────────── + validate: + name: Validate PR + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect platform + id: platform + run: | + # Read platform from XML manifest ( tag) or plain text fallback + PLATFORM=$(sed -n 's/.*\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1) + [ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]') + [ -z "$PLATFORM" ] && PLATFORM="generic" + echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT" + + - name: Setup PHP + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + if ! command -v php &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1 + fi + + - name: PHP syntax check + if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0) + echo "PHP lint: ${ERRORS} error(s)" + [ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; } + + - name: Validate platform manifest + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "::warning::No Joomla manifest found (WaaS site)" + exit 0 + fi + echo "Manifest: ${MANIFEST}" + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; } + fi + for ELEMENT in name version description; do + grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; } + done + echo "Joomla manifest valid" + ;; + dolibarr) + MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1) + if [ -z "$MOD_FILE" ]; then + echo "::error::No mod*.class.php found" + exit 1 + fi + echo "Dolibarr module: ${MOD_FILE}" + ;; + *) + echo "Generic platform — no manifest validation" + ;; + esac + + - name: Check update stream format + run: | + PLATFORM="${{ steps.platform.outputs.platform }}" + case "$PLATFORM" in + joomla) + if [ -f "updates.xml" ]; then + if command -v php &> /dev/null; then + php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; } + fi + echo "updates.xml valid" + fi + ;; + dolibarr) + [ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt" + ;; + esac + + - name: Check changelog has unreleased entry + run: | + if [ ! -f "CHANGELOG.md" ]; then + echo "::warning::No CHANGELOG.md found" + exit 0 + fi + # Check for content under [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md missing [Unreleased] section" + exit 1 + fi + # Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased + UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true) + if [ "$UNRELEASED_CONTENT" -eq 0 ]; then + echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes." + echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY + echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]" + + - name: Verify package source + run: | + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs" + if [ ! -d "$SOURCE_DIR" ]; then + echo "::warning::No src/ or htdocs/ directory" + exit 0 + fi + FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l) + echo "Source: ${FILE_COUNT} files" + [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } + diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml new file mode 100644 index 0000000..b34d35d --- /dev/null +++ b/.mokogitea/workflows/repo-health.yml @@ -0,0 +1,767 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Validation +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/joomla/repo_health.yml.template +# VERSION: 04.06.00 +# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. +# ============================================================================ + +name: "Generic: Repo Health" + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: 'Validation profile: all, release, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - release + - scripts + - repo + +permissions: + contents: read + +env: + # Release policy - Repository Variables Only + RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX + RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX + + # Scripts governance policy + SCRIPTS_REQUIRED_DIRS: + SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate + + # Repo health policy + REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.mokogitea/workflows/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # File / directory variables + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .mokogitea/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission (admin only) + id: perm + env: + TOKEN: ${{ secrets.MOKOGITEA_TOKEN || secrets.MOKOGITEA_TOKEN || github.token }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + ALLOWED=false + PERMISSION=unknown + METHOD="" + + # Hardcoded authorized users — always allowed + case "$ACTOR" in + jmiller|gitea-actions[bot]) + ALLOWED=true + PERMISSION=admin + METHOD="hardcoded allowlist" + ;; + *) + # Detect platform and check permissions via API + API_BASE="${GITHUB_API_URL:-${GITEA_API_URL:-https://api.github.com}}" + RESP=$(curl -sf -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/repos/${REPO}/collaborators/${ACTOR}/permission" 2>/dev/null || echo '{}') + PERMISSION=$(echo "$RESP" | grep -oP '"permission"\s*:\s*"\K[^"]+' || echo "unknown") + if [ "$PERMISSION" = "admin" ] || [ "$PERMISSION" = "maintain" ] || [ "$PERMISSION" = "owner" ]; then + ALLOWED=true + fi + METHOD="collaborator API" + ;; + esac + + echo "permission=${PERMISSION}" >> "$GITHUB_OUTPUT" + echo "allowed=${ALLOWED}" >> "$GITHUB_OUTPUT" + + { + echo "## Access Authorization" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Actor** | \`${ACTOR}\` |" + echo "| **Repository** | \`${REPO}\` |" + echo "| **Permission** | \`${PERMISSION}\` |" + echo "| **Method** | ${METHOD} |" + echo "| **Authorized** | ${ALLOWED} |" + echo "" + if [ "$ALLOWED" = "true" ]; then + echo "${ACTOR} authorized (${METHOD})" + else + echo "${ACTOR} is NOT authorized. Requires admin or maintain role." + fi + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Deny execution when not permitted + if: ${{ steps.perm.outputs.allowed != 'true' }} + run: | + set -euo pipefail + printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" + exit 1 + + release_config: + name: Release configuration + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Guardrails release vars + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }} + DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Release configuration (Repository Variables)' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes release validation' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}" + IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}" + + missing=() + missing_optional=() + + for k in "${required[@]}"; do + v="${!k:-}" + [ -z "${v}" ] && missing+=("${k}") + done + + for k in "${optional[@]}"; do + v="${!k:-}" + [ -z "${v}" ] && missing_optional+=("${k}") + done + + { + printf '%s\n' '### Release configuration (Repository Variables)' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Variable | Status |' + printf '%s\n' '|---|---|' + printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |" + printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repository variables' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#missing[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repository variables' + for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + { + printf '%s\n' '### Repository variables validation result' + printf '%s\n' 'Status: OK' + printf '%s\n' 'All required repository variables present.' + printf '%s\n' '' + printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + scripts_governance: + name: Scripts governance + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Scripts folder checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ -n "${SCRIPTS_REQUIRED_DIRS:-}" ]; then IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"; else required_dirs=(); fi + IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" + IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" + if [ -n "${REPO_DISALLOWED_DIRS:-}" ]; then IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"; else disallowed_dirs=(); fi + IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES:-}" + + missing_required=() + missing_optional=() + + # Source directory: src/ or htdocs/ (either is valid for extension repos) + SOURCE_DIR="" + if [ -d "src" ]; then + SOURCE_DIR="src" + elif [ -d "htdocs" ]; then + SOURCE_DIR="htdocs" + elif [ -d "deploy" ] || [ -d "cli" ] || [ -d "monitoring" ]; then + # Platform/tooling repos don't need src/ + SOURCE_DIR="" + else + missing_required+=("src/ or htdocs/ (source directory required)") + fi + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = 'dev' ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + fi + done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//') + + if [ "${#dev_paths[@]}" -eq 0 ] && [ "${#dev_branches[@]}" -eq 0 ]; then + missing_required+=("dev or dev/* branch") + fi + + content_warnings=() + + if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + fi + + if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export PROFILE_RAW="${profile}" + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")" + export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")" + + report_json=$(printf '{"profile":"%s","missing_required":%d,"missing_optional":%d,"content_warnings":%d}' "$profile" "${#missing_required[@]}" "${#missing_optional[@]}" "${#content_warnings[@]}") + + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # -- Joomla-specific checks -- + joomla_findings=() + + MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '/dev/null | head -1 || true)" + if [ -z "${MANIFEST}" ]; then + joomla_findings+=("Joomla XML manifest not found (no *.xml with tag)") + else + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then + joomla_findings+=("XML manifest: type attribute missing or invalid") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + if ! grep -qP ' missing (required for Joomla 5+)") + fi + fi + + INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)" + if [ "${INI_COUNT}" -eq 0 ]; then + joomla_findings+=("No .ini language files found") + fi + + if [ ! -f 'updates.xml' ]; then + joomla_findings+=("updates.xml missing in root (required for Joomla update server)") + fi + + if [ -n "${SOURCE_DIR}" ]; then + INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site") + for dir in "${INDEX_DIRS[@]}"; do + if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then + joomla_findings+=("${dir}/index.html missing (directory listing protection)") + fi + done + fi + + if [ "${#joomla_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' '| Check | Status |' + printf '%s\n' '|---|---|' + for f in "${joomla_findings[@]}"; do + printf '%s\n' "| ${f} | Warning |" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Joomla extension checks' + printf '%s\n' 'All Joomla-specific checks passed.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -f "${DOCS_INDEX}" ]; then + missing_links="" + while IFS= read -r docline; do + for link in $(echo "$docline" | grep -oE '\]\([^)]+\)' | sed 's/\](//' | sed 's/)$//' || true); do + case "$link" in http://*|https://*|"#"*|mailto:*) continue ;; esac + linkpath="${link%%#*}" + linkpath="${linkpath%%\?*}" + [ -z "$linkpath" ] && continue + if [ "${linkpath:0:1}" = "/" ]; then + testpath="${linkpath#/}" + else + testpath="$(dirname "${DOCS_INDEX}")/${linkpath}" + fi + [ ! -e "$testpath" ] && missing_links="${missing_links}${testpath} " + done + done < "${DOCS_INDEX}" + if [ -n "${missing_links}" ]; then + extended_findings+=("docs/docs-index.md contains broken relative links") + { + printf '%s\n' '### Docs index link integrity' + printf '%s\n' 'Broken relative links:' + for bl in ${missing_links}; do + printf '%s\n' "- ${bl}" + done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | head -50)" + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + printf '%s\n' '| Domain | Status | Notes |' + printf '%s\n' '|---|---|---|' + printf '%s\n' '| Access control | OK | Admin-only execution gate |' + printf '%s\n' '| Release variables | OK | Repository variables validation |' + printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' + printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' + printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" + + + site-health: + name: Site Health + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + + - name: Uptime check + if: env.URLS != '' + run: | + echo "$URLS" > /tmp/urls.txt + php monitoring/uptime-probe.php --urls /tmp/urls.txt --timeout 15 || echo "::warning::Some sites are down" + rm -f /tmp/urls.txt + env: + URLS: ${{ vars.MONITORED_URLS }} + + - name: SSL certificate check + if: env.DOMAINS != '' + run: | + echo "$DOMAINS" > /tmp/domains.txt + php monitoring/ssl-check.php --domains /tmp/domains.txt --warn-days 30 || echo "::warning::SSL certificates expiring soon" + rm -f /tmp/domains.txt + env: + DOMAINS: ${{ vars.MONITORED_DOMAINS }} + + - name: Summary + if: always() + run: | + echo "### Site Health" >> $GITHUB_STEP_SUMMARY + echo "Uptime and SSL checks completed." >> $GITHUB_STEP_SUMMARY + diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml new file mode 100644 index 0000000..714d407 --- /dev/null +++ b/.mokogitea/workflows/security-audit.yml @@ -0,0 +1,98 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Security +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.gitea/workflows/security-audit.yml +# VERSION: 01.00.00 +# BRIEF: Dependency vulnerability scanning for composer and npm packages + +name: "Universal: Security Audit" + +on: + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC + pull_request: + branches: + - main + paths: + - 'composer.json' + - 'composer.lock' + - 'package.json' + - 'package-lock.json' + workflow_dispatch: + +permissions: + contents: read + +env: + NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }} + NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }} + +jobs: + audit: + name: Dependency Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Composer audit + if: hashFiles('composer.lock') != '' + run: | + echo "=== Composer Security Audit ===" + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq + sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1 + fi + composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt + RESULT=$? + if [ $RESULT -ne 0 ]; then + echo "::warning::Composer vulnerabilities found" + echo "composer_vulnerable=true" >> "$GITHUB_ENV" + else + echo "No known vulnerabilities in composer dependencies" + fi + + - name: NPM audit + if: hashFiles('package-lock.json') != '' + run: | + echo "=== NPM Security Audit ===" + npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true + if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then + echo "No known vulnerabilities in npm dependencies" + else + echo "::warning::NPM vulnerabilities found" + echo "npm_vulnerable=true" >> "$GITHUB_ENV" + fi + + - name: Notify on vulnerabilities + if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true' + run: | + REPO="${{ github.event.repository.name }}" + curl -sS \ + -H "Title: ${REPO} has vulnerable dependencies" \ + -H "Tags: lock,warning" \ + -H "Priority: high" \ + -d "Security audit found vulnerabilities. Review dependency updates." \ + "${NTFY_URL}/${NTFY_TOPIC}" || true + + + - name: Joomla version audit + if: always() + run: | + if [ -f "monitoring/joomla-version-audit.php" ] && [ -n "$JOOMLA_SITES" ]; then + echo "$JOOMLA_SITES" > /tmp/sites.json + php monitoring/joomla-version-audit.php --sites /tmp/sites.json || true + echo "### Joomla Version Audit" >> $GITHUB_STEP_SUMMARY + rm -f /tmp/sites.json + else + echo "Joomla audit skipped (no script or JOOMLA_SITES_JSON not configured)" + fi + env: + JOOMLA_SITES: ${{ vars.JOOMLA_SITES_JSON }} + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..271ddee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## [Unreleased] + +### Added +- Initial package structure with component, system plugin, and webservices plugin +- Backup engine with step-based execution for large sites +- Database dumper with table-level granularity +- File scanner with directory exclusion filters +- ZIP archive builder +- Backup profiles with independent configurations +- Backup record management (list, download, delete) +- Admin dashboard with backup history +- CLI script for cron/scheduled backups +- REST API compatible with MokoBackup MCP server +- System plugin for scheduled backup triggers +- Automatic old backup cleanup with configurable retention diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..50bcfb4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this repository. + +## Project Overview + +**MokoJoomBackup** -- Full-site backup and restore for Joomla — database, files, and configuration + +| Field | Value | +|---|---| +| **Platform** | joomla | +| **Language** | PHP | +| **Default branch** | main | +| **License** | GPL-3.0-or-later | +| **Wiki** | [MokoJoomBackup Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/wiki) | +| **Standards** | [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) | + +## Common Commands + +```bash +make build # Build the project +make lint # Run linters +make validate # Validate structure +make release # Full release pipeline +make minify # Minify CSS/JS assets +make clean # Clean build artifacts +``` + +```bash +composer install # Install PHP dependencies +``` + +## Architecture + +This is a Joomla **package** extension (`pkg_mokobackup`) containing three sub-extensions: + +### com_mokobackup (Component) +- Admin backend for managing backup profiles and backup records +- Backup engine: `Engine/BackupEngine`, `Engine/DatabaseDumper`, `Engine/FileScanner`, `Engine/Archiver` +- Joomla 4/5 MVC: Controllers, Models, Views, Tables +- Namespace: `Joomla\Component\MokoBackup\Administrator` +- Database tables: `#__mokobackup_profiles`, `#__mokobackup_records` +- CLI: `cli/mokobackup.php` for cron-based backups + +### plg_system_mokobackup (System Plugin) +- Handles scheduled backup triggers +- Cleanup of expired backup archives +- Namespace: `Joomla\Plugin\System\MokoBackup` + +### plg_webservices_mokobackup (WebServices Plugin) +- REST API for remote backup management +- Wire-compatible with existing mcp_mokobackup MCP server +- Endpoints: backup, backups, profiles, download, delete +- Namespace: `Joomla\Plugin\WebServices\MokoBackup` + +### Database Schema + +Two tables: +- `#__mokobackup_profiles` — backup profiles (name, description, config JSON, filters JSON) +- `#__mokobackup_records` — backup records (profile_id, status, origin, archive path, sizes, timestamps) + +## Rules + +- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, or `*.min.css`/`*.min.js` +- **Attribution**: use `Authored-by: Moko Consulting` in commits +- **Branch strategy**: develop on `dev`, merge to `main` for release +- **Minification**: handled at build time (CI) +- **Wiki**: documentation lives in the Gitea wiki, not in `docs/` files +- **Standards**: this repo follows [MokoStandards](https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home) + +## Coding Standards + +- PHP 8.1+ minimum +- Joomla 4/5 DI container pattern: `services/provider.php` > Extension class +- Legacy stub `.php` file required for plugin loader but empty +- `SubscriberInterface` for event subscription (not `on*` method naming) +- `bind() > check() > store()` for Table operations (not `save()`) +- Language file placement: site (no `folder`) vs admin (`folder="administrator"`) +- SPDX license headers on all PHP files diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..791100e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing to MokoJoomBackup + +Thank you for your interest in contributing to MokoJoomBackup. + +## Getting Started + +1. Fork the repository on Gitea +2. Create a feature branch from `dev` (`feature/your-feature`) +3. Make your changes following the coding standards below +4. Submit a pull request targeting `dev` + +## Branch Strategy + +- `main` — stable releases only +- `dev` — active development +- `feature/*` — new features (target `dev`) +- `fix/*` — bug fixes (target `dev`) +- `hotfix/*` — urgent fixes (target `dev` or `main`) + +## Coding Standards + +- PHP 8.1+ required +- Follow Joomla coding standards +- SPDX license headers on all PHP files +- Use `SubscriberInterface` for event subscription +- Use `bind() -> check() -> store()` for Table operations + +## Reporting Issues + +Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/issues). + +## License + +By contributing, you agree that your contributions will be licensed under GPL-3.0-or-later. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07f55a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + For the full license text, see https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..50e3eae --- /dev/null +++ b/Makefile @@ -0,0 +1,203 @@ +# Makefile for Joomla Extensions +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# +# MokoJoomBackup — Full-site backup and restore for Joomla + +# ============================================================================== +# CONFIGURATION - Customize these for your extension +# ============================================================================== + +# Extension Configuration +EXTENSION_NAME := mokobackup +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 +BUILD_DIR := build +DIST_DIR := dist +DOCS_DIR := docs + +# Joomla Installation (for local testing - customize paths) +JOOMLA_ROOT := /var/www/html/joomla +JOOMLA_VERSION := 4 + +# Tools +PHP := php +COMPOSER := composer +NPM := npm +PHPCS := vendor/bin/phpcs +PHPCBF := vendor/bin/phpcbf +PHPUNIT := vendor/bin/phpunit +ZIP := zip + +# Coding Standards +PHPCS_STANDARD := Joomla + +# Colors for output +COLOR_RESET := \033[0m +COLOR_GREEN := \033[32m +COLOR_YELLOW := \033[33m +COLOR_BLUE := \033[34m +COLOR_RED := \033[31m + +# ============================================================================== +# TARGETS +# ============================================================================== + +.PHONY: help +help: ## Show this help message + @echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)" + @echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)" + @echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)" + @echo "" + @echo "Extension: $(EXTENSION_NAME) ($(EXTENSION_TYPE)) v$(EXTENSION_VERSION)" + @echo "" + @echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(COLOR_BLUE)%-20s$(COLOR_RESET) %s\n", $$1, $$2}' + @echo "" + +.PHONY: install-deps +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 +lint: ## Run PHP linter (syntax check) + @echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)" + @find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \ + -exec $(PHP) -l {} \; | grep -v "No syntax errors" || true + @echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)" + +.PHONY: phpcs +phpcs: ## Run PHP CodeSniffer (Joomla standards) + @echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)" + @if [ -f "$(PHPCS)" ]; then \ + $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \ + else \ + echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \ + fi + +.PHONY: validate +validate: lint phpcs ## Run all validation checks + @echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)" + +.PHONY: clean +clean: ## Clean build artifacts + @echo "$(COLOR_BLUE)Cleaning build artifacts...$(COLOR_RESET)" + @rm -rf $(BUILD_DIR) $(DIST_DIR) + @echo "$(COLOR_GREEN)✓ Build artifacts cleaned$(COLOR_RESET)" + +MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform)) +MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js + +.PHONY: minify +minify: ## Minify CSS/JS assets + @echo "Minifying assets..." + @if [ -f "$(MINIFY_SCRIPT)" ]; then \ + node "$(MINIFY_SCRIPT)" $(SRC_DIR); \ + elif [ -f "scripts/minify.js" ]; then \ + node scripts/minify.js; \ + else \ + echo "No minify script found"; \ + fi + +.PHONY: build +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 +release: validate build ## Create a release (validate + build) + @echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)" + +.PHONY: version +version: ## Display version information + @echo "$(COLOR_BLUE)Extension Information:$(COLOR_RESET)" + @echo " Name: $(EXTENSION_NAME)" + @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_GOAL := help diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec51852 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# MokoJoomBackup + + + +Full-site backup and restore for Joomla — database, files, and configuration. + +## Overview + +MokoJoomBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management. + +## Features + +- Full site backup (database + files + configuration) +- Database-only backup mode +- Files-only backup mode +- Multiple backup profiles with independent configurations +- File and directory exclusion filters +- Table exclusion filters for database backups +- Step-based backup engine (avoids PHP timeout on large sites) +- CLI script for cron/scheduled backups +- REST API (Joomla Web Services) for remote management +- Backup record management (list, download, delete) +- Automatic old backup cleanup (configurable retention) +- Admin dashboard with backup history and storage usage + +## Installation + +1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases) +2. Joomla Administrator > Extensions > Install +3. System plugin enabled automatically on install + +## Configuration + +- **Component**: Administrator > Components > MokoJoomBackup +- **Profiles**: Create backup profiles with different file/database filters +- **System Plugin**: Configure scheduled backup triggers and notifications +- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups + +## REST API + +The webservices plugin exposes endpoints compatible with the MokoBackup MCP server: + +- `POST /api/index.php/v1/mokobackup/backup` — Start a backup +- `GET /api/index.php/v1/mokobackup/backups` — List backup records +- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive +- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record +- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles + +## License + +GPL-3.0-or-later + +## Author + +[Moko Consulting](https://mokoconsulting.tech) — hello@mokoconsulting.tech diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7df7e90 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "mokoconsulting/mokobackup", + "description": "Full-site backup and restore for Joomla — database, files, and configuration", + "type": "joomla-package", + "version": "01.00.00", + "license": "GPL-3.0-or-later", + "authors": [ + { + "name": "Moko Consulting", + "email": "hello@mokoconsulting.tech", + "homepage": "https://mokoconsulting.tech" + } + ], + "require": { + "php": ">=8.1" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.7", + "phpstan/phpstan": "^1.10", + "joomla/coding-standards": "^3.0" + }, + "config": { + "sort-packages": true + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..3d4adca --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,32 @@ +# Copyright (C) 2026 Moko Consulting +# SPDX-License-Identifier: GPL-3.0-or-later +# +# PHPStan configuration for Joomla extension repositories. +# Extends the base MokoStandards config and adds Joomla framework class stubs +# so PHPStan can resolve Factory, CMSApplication, User, Table, etc. +# without requiring a full Joomla installation. + +parameters: + level: 5 + + paths: + - src + + excludePaths: + - vendor + - node_modules + + # Joomla framework stubs — resolved via the enterprise package from vendor/ + stubFiles: + - vendor/mokoconsulting-tech/enterprise/templates/stubs/joomla.php + + # Suppress errors that are structural in Joomla's service-container architecture + ignoreErrors: + # Joomla's service-based dependency injection returns mixed from getApplication() + - '#Cannot call method .+ on Joomla\\CMS\\Application\\CMSApplication\|null#' + # Factory::getX() patterns are safe at runtime even when nullable in stubs + - '#Call to static method [a-zA-Z]+\(\) on an interface#' + + reportUnmatchedIgnoredErrors: false + checkMissingIterableValueType: false + checkGenericClassInNonGenericObjectType: false diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/index.html @@ -0,0 +1 @@ + diff --git a/src/language/en-GB/index.html b/src/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/language/en-GB/pkg_mokobackup.sys.ini b/src/language/en-GB/pkg_mokobackup.sys.ini new file mode 100644 index 0000000..071172a --- /dev/null +++ b/src/language/en-GB/pkg_mokobackup.sys.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Package language file (en-GB) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +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_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." diff --git a/src/language/en-US/index.html b/src/language/en-US/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/language/en-US/index.html @@ -0,0 +1 @@ + diff --git a/src/language/en-US/pkg_mokobackup.sys.ini b/src/language/en-US/pkg_mokobackup.sys.ini new file mode 100644 index 0000000..9a32545 --- /dev/null +++ b/src/language/en-US/pkg_mokobackup.sys.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — Package language file (en-US) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +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_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." diff --git a/src/language/index.html b/src/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/api/index.html b/src/packages/com_mokobackup/api/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/api/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/api/src/Controller/BackupsController.php b/src/packages/com_mokobackup/api/src/Controller/BackupsController.php new file mode 100644 index 0000000..ab7c746 --- /dev/null +++ b/src/packages/com_mokobackup/api/src/Controller/BackupsController.php @@ -0,0 +1,100 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Api\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\ApiController; +use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; + +class BackupsController extends ApiController +{ + protected $contentType = 'backups'; + protected $default_view = 'backups'; + + /** + * Start a new backup (POST /api/index.php/v1/mokobackup/backup) + */ + public function backup(): static + { + $data = json_decode($this->input->json->getRaw(), true) ?: []; + + $profileId = (int) ($data['profile'] ?? 1); + $description = $data['description'] ?? 'API backup ' . date('Y-m-d H:i:s'); + + $engine = new BackupEngine(); + $result = $engine->run($profileId, $description, 'api'); + + if ($result['success']) { + $this->app->setHeader('status', 200); + echo json_encode(['data' => $result]); + } else { + $this->app->setHeader('status', 500); + echo json_encode(['errors' => [['title' => $result['message']]]]); + } + + $this->app->close(); + + return $this; + } + + /** + * Download a backup archive (GET /api/index.php/v1/mokobackup/backup/:id/download) + */ + public function download(): static + { + $id = $this->input->getInt('id', 0); + + $model = $this->getModel('Backup', 'Administrator'); + $item = $model->getItem($id); + + if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) { + $this->app->setHeader('status', 404); + echo json_encode(['errors' => [['title' => 'Backup file not found']]]); + $this->app->close(); + + return $this; + } + + $content = base64_encode(file_get_contents($item->absolute_path)); + + $this->app->setHeader('status', 200); + echo json_encode(['data' => $content]); + $this->app->close(); + + return $this; + } + + /** + * List backup profiles (GET /api/index.php/v1/mokobackup/profiles) + */ + public function profiles(): static + { + $model = $this->getModel('Profiles', 'Administrator'); + $items = $model->getItems(); + + $data = []; + + foreach ($items as $item) { + $data[] = [ + 'type' => 'profiles', + 'id' => $item->id, + 'attributes' => $item, + ]; + } + + $this->app->setHeader('status', 200); + echo json_encode(['data' => $data]); + $this->app->close(); + + return $this; + } +} diff --git a/src/packages/com_mokobackup/api/src/Controller/index.html b/src/packages/com_mokobackup/api/src/Controller/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/api/src/Controller/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php b/src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php new file mode 100644 index 0000000..d9e8a8a --- /dev/null +++ b/src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php @@ -0,0 +1,53 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoBackup\Api\View\Backups; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\View\JsonApiView as BaseApiView; + +class JsonapiView extends BaseApiView +{ + protected $fieldsToRenderItem = [ + 'id', + 'profile_id', + 'description', + 'status', + 'origin', + 'backup_type', + 'archivename', + 'absolute_path', + 'total_size', + 'db_size', + 'files_count', + 'tables_count', + 'multipart', + 'tag', + 'backupstart', + 'backupend', + 'filesexist', + 'remote_filename', + ]; + + protected $fieldsToRenderList = [ + 'id', + 'profile_id', + 'description', + 'status', + 'origin', + 'backup_type', + 'archivename', + 'total_size', + 'backupstart', + 'backupend', + 'filesexist', + ]; +} diff --git a/src/packages/com_mokobackup/api/src/View/Backups/index.html b/src/packages/com_mokobackup/api/src/View/Backups/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/api/src/View/Backups/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/api/src/View/index.html b/src/packages/com_mokobackup/api/src/View/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/api/src/View/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/api/src/index.html b/src/packages/com_mokobackup/api/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/api/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/cli/index.html b/src/packages/com_mokobackup/cli/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/cli/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/cli/mokobackup.php b/src/packages/com_mokobackup/cli/mokobackup.php new file mode 100644 index 0000000..47f030e --- /dev/null +++ b/src/packages/com_mokobackup/cli/mokobackup.php @@ -0,0 +1,68 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * CLI backup script for cron/scheduled use. + * + * Usage: + * php cli/mokobackup.php --profile=1 --description="Scheduled backup" + * + * Must be run from the Joomla root directory. + */ + +// Define Joomla constants +const _JEXEC = 1; + +// Bootstrap Joomla +if (file_exists(dirname(__DIR__, 4) . '/includes/defines.php')) { + require_once dirname(__DIR__, 4) . '/includes/defines.php'; +} + +if (!defined('JPATH_BASE')) { + define('JPATH_BASE', dirname(__DIR__, 4)); +} + +require_once JPATH_BASE . '/includes/framework.php'; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; + +// Parse CLI arguments +$profileId = 1; +$description = ''; + +foreach ($argv as $arg) { + if (str_starts_with($arg, '--profile=')) { + $profileId = (int) substr($arg, 10); + } elseif (str_starts_with($arg, '--description=')) { + $description = substr($arg, 14); + } +} + +if (empty($description)) { + $description = 'CLI backup ' . date('Y-m-d H:i:s'); +} + +// Boot the application +$app = Factory::getApplication('administrator'); + +echo "MokoJoomBackup CLI\n"; +echo "Profile: {$profileId}\n"; +echo "Description: {$description}\n"; +echo "Starting backup...\n\n"; + +$engine = new BackupEngine(); +$result = $engine->run($profileId, $description, 'cli'); + +if ($result['success']) { + echo "SUCCESS: " . $result['message'] . "\n"; + exit(0); +} else { + echo "FAILED: " . $result['message'] . "\n"; + exit(1); +} diff --git a/src/packages/com_mokobackup/forms/backup.xml b/src/packages/com_mokobackup/forms/backup.xml new file mode 100644 index 0000000..6d1e1e8 --- /dev/null +++ b/src/packages/com_mokobackup/forms/backup.xml @@ -0,0 +1,15 @@ + +
+
+ + + + + + + + + + +
+
diff --git a/src/packages/com_mokobackup/forms/filter_backups.xml b/src/packages/com_mokobackup/forms/filter_backups.xml new file mode 100644 index 0000000..d22dcb8 --- /dev/null +++ b/src/packages/com_mokobackup/forms/filter_backups.xml @@ -0,0 +1,47 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokobackup/forms/filter_profiles.xml b/src/packages/com_mokobackup/forms/filter_profiles.xml new file mode 100644 index 0000000..0025a94 --- /dev/null +++ b/src/packages/com_mokobackup/forms/filter_profiles.xml @@ -0,0 +1,44 @@ + +
+ + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokobackup/forms/index.html b/src/packages/com_mokobackup/forms/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/forms/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/forms/profile.xml b/src/packages/com_mokobackup/forms/profile.xml new file mode 100644 index 0000000..00ed3fa --- /dev/null +++ b/src/packages/com_mokobackup/forms/profile.xml @@ -0,0 +1,74 @@ + +
+
+ + + + + + + + +
+ +
+ + + + + + +
+ +
+ +
+
diff --git a/src/packages/com_mokobackup/index.html b/src/packages/com_mokobackup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini new file mode 100644 index 0000000..0cf4a7a --- /dev/null +++ b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.ini @@ -0,0 +1,91 @@ +; MokoJoomBackup — Component language file (en-GB) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +COM_MOKOBACKUP="MokoJoomBackup" +COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" + +; Submenu +COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" + +; Backups view +COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records" +COM_MOKOBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records" +COM_MOKOBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." +COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" +COM_MOKOBACKUP_DOWNLOAD="Download" + +; Backup detail view +COM_MOKOBACKUP_BACKUP_DETAIL="Backup Detail" + +; Profiles view +COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles" +COM_MOKOBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles" +COM_MOKOBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOBACKUP_PROFILE_NEW="New Profile" +COM_MOKOBACKUP_PROFILE_EDIT="Edit Profile" + +; Table headings +COM_MOKOBACKUP_HEADING_DESCRIPTION="Description" +COM_MOKOBACKUP_HEADING_PROFILE="Profile" +COM_MOKOBACKUP_HEADING_STATUS="Status" +COM_MOKOBACKUP_HEADING_TYPE="Type" +COM_MOKOBACKUP_HEADING_SIZE="Size" +COM_MOKOBACKUP_HEADING_DATE="Date" +COM_MOKOBACKUP_HEADING_ACTIONS="Actions" +COM_MOKOBACKUP_HEADING_TITLE="Title" +COM_MOKOBACKUP_HEADING_DATE_DESC="Date descending" +COM_MOKOBACKUP_HEADING_DATE_ASC="Date ascending" +COM_MOKOBACKUP_HEADING_SIZE_DESC="Size descending" +COM_MOKOBACKUP_HEADING_SIZE_ASC="Size ascending" +COM_MOKOBACKUP_HEADING_TITLE_ASC="Title ascending" +COM_MOKOBACKUP_HEADING_TITLE_DESC="Title descending" + +; Fields +COM_MOKOBACKUP_FIELD_TITLE="Title" +COM_MOKOBACKUP_FIELD_TITLE_DESC="Profile name" +COM_MOKOBACKUP_FIELD_DESCRIPTION="Description" +COM_MOKOBACKUP_FIELD_DESCRIPTION_DESC="Brief description of this profile" +COM_MOKOBACKUP_FIELD_BACKUP_TYPE="Backup Type" +COM_MOKOBACKUP_FIELD_BACKUP_TYPE_DESC="What to include in the backup" +COM_MOKOBACKUP_FIELD_CONFIG="Configuration (JSON)" +COM_MOKOBACKUP_FIELD_CONFIG_DESC="JSON configuration for archive format, compression, and backup directory" +COM_MOKOBACKUP_FIELD_FILTERS="Filters (JSON)" +COM_MOKOBACKUP_FIELD_FILTERS_DESC="JSON filters for excluding directories, files, and database tables" +COM_MOKOBACKUP_FIELD_STATUS="Status" +COM_MOKOBACKUP_FIELD_ORIGIN="Origin" +COM_MOKOBACKUP_FIELD_SIZE="Total Size" +COM_MOKOBACKUP_FIELD_START="Start Time" +COM_MOKOBACKUP_FIELD_END="End Time" +COM_MOKOBACKUP_FIELD_ARCHIVE="Archive Name" +COM_MOKOBACKUP_FIELD_FILES_COUNT="Files Count" +COM_MOKOBACKUP_FIELD_TABLES_COUNT="Tables Count" + +; Backup types +COM_MOKOBACKUP_TYPE_FULL="Full Site (Database + Files)" +COM_MOKOBACKUP_TYPE_DATABASE="Database Only" +COM_MOKOBACKUP_TYPE_FILES="Files Only" + +; Status labels +COM_MOKOBACKUP_STATUS_COMPLETE="Complete" +COM_MOKOBACKUP_STATUS_RUNNING="Running" +COM_MOKOBACKUP_STATUS_FAIL="Failed" +COM_MOKOBACKUP_STATUS_PENDING="Pending" + +; Filters +COM_MOKOBACKUP_FILTER_SEARCH="Search" +COM_MOKOBACKUP_FILTER_STATUS="Status" +COM_MOKOBACKUP_FILTER_STATUS_ALL="- Select Status -" + +; Tabs +COM_MOKOBACKUP_TAB_GENERAL="General" +COM_MOKOBACKUP_TAB_FILTERS="Exclusion Filters" +COM_MOKOBACKUP_FIELDSET_GENERAL="General" +COM_MOKOBACKUP_FIELDSET_STATUS="Status" +COM_MOKOBACKUP_FIELDSET_FILTERS="Exclusion Filters" + +; Errors +COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." diff --git a/src/packages/com_mokobackup/language/en-GB/com_mokobackup.sys.ini b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.sys.ini new file mode 100644 index 0000000..17e8576 --- /dev/null +++ b/src/packages/com_mokobackup/language/en-GB/com_mokobackup.sys.ini @@ -0,0 +1,10 @@ +; MokoJoomBackup — Component system language file (en-GB) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +COM_MOKOBACKUP="MokoJoomBackup" +COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." +COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" diff --git a/src/packages/com_mokobackup/language/en-GB/index.html b/src/packages/com_mokobackup/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini b/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini new file mode 100644 index 0000000..2f000f1 --- /dev/null +++ b/src/packages/com_mokobackup/language/en-US/com_mokobackup.ini @@ -0,0 +1,15 @@ +; MokoJoomBackup — Component language file (en-US) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +COM_MOKOBACKUP="MokoJoomBackup" +COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" +COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" +COM_MOKOBACKUP_BACKUPS_TITLE="Backup Records" +COM_MOKOBACKUP_PROFILES_TITLE="Backup Profiles" +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_PROFILES="No backup profiles found." diff --git a/src/packages/com_mokobackup/language/en-US/com_mokobackup.sys.ini b/src/packages/com_mokobackup/language/en-US/com_mokobackup.sys.ini new file mode 100644 index 0000000..96e51f2 --- /dev/null +++ b/src/packages/com_mokobackup/language/en-US/com_mokobackup.sys.ini @@ -0,0 +1,10 @@ +; MokoJoomBackup — Component system language file (en-US) +; @package MokoJoomBackup +; @author Moko Consulting +; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. +; @license GPL-3.0-or-later + +COM_MOKOBACKUP="MokoJoomBackup" +COM_MOKOBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." +COM_MOKOBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOBACKUP_SUBMENU_PROFILES="Backup Profiles" diff --git a/src/packages/com_mokobackup/language/en-US/index.html b/src/packages/com_mokobackup/language/en-US/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/language/en-US/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/language/index.html b/src/packages/com_mokobackup/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/mokobackup.xml b/src/packages/com_mokobackup/mokobackup.xml new file mode 100644 index 0000000..efe0b01 --- /dev/null +++ b/src/packages/com_mokobackup/mokobackup.xml @@ -0,0 +1,89 @@ + + + + com_mokobackup + 01.00.00-dev + 2026-06-02 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + COM_MOKOBACKUP_DESCRIPTION + + Joomla\Component\MokoBackup + + script.php + + + + sql/install.mysql.sql + + + + + + sql/uninstall.mysql.sql + + + + + + sql/updates/mysql + + + + + + provider.php + + + Controller + Engine + Extension + Model + Table + View + + + backup.xml + profile.xml + filter_backups.xml + filter_profiles.xml + + + backups + backup + profiles + profile + + + mysql + updates + + + mokobackup.php + + + en-GB/com_mokobackup.ini + en-GB/com_mokobackup.sys.ini + + COM_MOKOBACKUP + + COM_MOKOBACKUP_SUBMENU_BACKUPS + COM_MOKOBACKUP_SUBMENU_PROFILES + + + + + + src + + + diff --git a/src/packages/com_mokobackup/services/index.html b/src/packages/com_mokobackup/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/services/provider.php b/src/packages/com_mokobackup/services/provider.php new file mode 100644 index 0000000..cd6bc5b --- /dev/null +++ b/src/packages/com_mokobackup/services/provider.php @@ -0,0 +1,40 @@ + + * @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\Dispatcher\ComponentDispatcherFactoryInterface; +use Joomla\CMS\Extension\ComponentInterface; +use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory; +use Joomla\CMS\Extension\Service\Provider\MVCFactory; +use Joomla\CMS\MVC\Factory\MVCFactoryInterface; +use Joomla\Component\MokoBackup\Administrator\Extension\MokoBackupComponent; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\MokoBackup')); + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\MokoBackup')); + + $container->set( + ComponentInterface::class, + function (Container $container) { + $component = new MokoBackupComponent( + $container->get(ComponentDispatcherFactoryInterface::class) + ); + $component->setMVCFactory($container->get(MVCFactoryInterface::class)); + + return $component; + } + ); + } +}; diff --git a/src/packages/com_mokobackup/sql/index.html b/src/packages/com_mokobackup/sql/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/sql/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/src/packages/com_mokobackup/sql/install.mysql.sql new file mode 100644 index 0000000..e24c314 --- /dev/null +++ b/src/packages/com_mokobackup/sql/install.mysql.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL DEFAULT '', + `description` TEXT NOT NULL, + `backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files', + `config` MEDIUMTEXT NOT NULL COMMENT 'JSON: archive format, compression, paths', + `filters` MEDIUMTEXT NOT NULL COMMENT 'JSON: excluded dirs, files, tables', + `published` TINYINT(1) NOT NULL DEFAULT 1, + `ordering` INT(11) NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + `modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (`id`), + KEY `idx_published` (`published`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( + `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + `profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1, + `description` VARCHAR(255) NOT NULL DEFAULT '', + `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, running, complete, fail', + `origin` VARCHAR(20) NOT NULL DEFAULT 'backend' COMMENT 'backend, cli, api, scheduled', + `backup_type` VARCHAR(20) NOT NULL DEFAULT 'full' COMMENT 'full, database, files', + `archivename` VARCHAR(512) NOT NULL DEFAULT '', + `absolute_path` VARCHAR(1024) NOT NULL DEFAULT '', + `total_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `db_size` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `files_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `tables_count` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `multipart` INT(11) UNSIGNED NOT NULL DEFAULT 0, + `tag` VARCHAR(50) NOT NULL DEFAULT '', + `backupstart` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + `backupend` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + `filesexist` TINYINT(1) NOT NULL DEFAULT 1, + `remote_filename` VARCHAR(512) NOT NULL DEFAULT '', + `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', + PRIMARY KEY (`id`), + KEY `idx_profile` (`profile_id`), + KEY `idx_status` (`status`), + KEY `idx_backupstart` (`backupstart`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Insert default backup profile +INSERT INTO `#__mokobackup_profiles` (`id`, `title`, `description`, `backup_type`, `config`, `filters`, `published`, `ordering`, `created`, `modified`) +VALUES (1, 'Default Backup Profile', 'Full site backup with default settings', 'full', + '{"archive_format":"zip","compression_level":5,"split_size":0,"backup_dir":"administrator/components/com_mokobackup/backups"}', + '{"exclude_dirs":["administrator/components/com_mokobackup/backups","tmp","cache","logs","administrator/logs"],"exclude_files":[".gitignore",".htaccess.bak"],"exclude_tables":["#__session"]}', + 1, 1, NOW(), NOW()); diff --git a/src/packages/com_mokobackup/sql/mysql/index.html b/src/packages/com_mokobackup/sql/mysql/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/sql/mysql/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/sql/uninstall.mysql.sql b/src/packages/com_mokobackup/sql/uninstall.mysql.sql new file mode 100644 index 0000000..8df7cde --- /dev/null +++ b/src/packages/com_mokobackup/sql/uninstall.mysql.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `#__mokobackup_records`; +DROP TABLE IF EXISTS `#__mokobackup_profiles`; diff --git a/src/packages/com_mokobackup/sql/updates/index.html b/src/packages/com_mokobackup/sql/updates/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql b/src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql new file mode 100644 index 0000000..667cc10 --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql @@ -0,0 +1 @@ +-- Initial release — no updates needed (tables created by install.mysql.sql) diff --git a/src/packages/com_mokobackup/sql/updates/mysql/index.html b/src/packages/com_mokobackup/sql/updates/mysql/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/sql/updates/mysql/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/Controller/BackupController.php b/src/packages/com_mokobackup/src/Controller/BackupController.php new file mode 100644 index 0000000..459fe3f --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/BackupController.php @@ -0,0 +1,20 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\FormController; + +class BackupController extends FormController +{ + protected $text_prefix = 'COM_MOKOBACKUP_BACKUP'; +} diff --git a/src/packages/com_mokobackup/src/Controller/BackupsController.php b/src/packages/com_mokobackup/src/Controller/BackupsController.php new file mode 100644 index 0000000..1b6bbc1 --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/BackupsController.php @@ -0,0 +1,82 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\AdminController; +use Joomla\CMS\Router\Route; +use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; + +class BackupsController extends AdminController +{ + protected $text_prefix = 'COM_MOKOBACKUP_BACKUPS'; + + public function getModel($name = 'Backup', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } + + /** + * Start a new backup using the specified profile. + * + * @return void + */ + public function start(): void + { + $this->checkToken(); + + $profileId = $this->input->getInt('profile_id', 1); + $description = $this->input->getString('description', ''); + + $engine = new BackupEngine(); + $result = $engine->run($profileId, $description, 'backend'); + + if ($result['success']) { + $this->setMessage($result['message']); + } else { + $this->setMessage($result['message'], 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + } + + /** + * Download a backup archive. + * + * @return void + */ + public function download(): void + { + $id = $this->input->getInt('id', 0); + $model = $this->getModel('Backup'); + $item = $model->getItem($id); + + if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) { + $this->setMessage('COM_MOKOBACKUP_ERROR_FILE_NOT_FOUND', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + + return; + } + + $app = $this->app; + $app->clearHeaders(); + $app->setHeader('Content-Type', 'application/zip'); + $app->setHeader('Content-Disposition', 'attachment; filename="' . basename($item->archivename) . '"'); + $app->setHeader('Content-Length', (string) filesize($item->absolute_path)); + $app->setHeader('Cache-Control', 'no-cache, must-revalidate'); + $app->sendHeaders(); + + readfile($item->absolute_path); + + $app->close(); + } +} diff --git a/src/packages/com_mokobackup/src/Controller/DisplayController.php b/src/packages/com_mokobackup/src/Controller/DisplayController.php new file mode 100644 index 0000000..5e4ec11 --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/DisplayController.php @@ -0,0 +1,20 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\BaseController; + +class DisplayController extends BaseController +{ + protected $default_view = 'backups'; +} diff --git a/src/packages/com_mokobackup/src/Controller/ProfileController.php b/src/packages/com_mokobackup/src/Controller/ProfileController.php new file mode 100644 index 0000000..5a84e2e --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/ProfileController.php @@ -0,0 +1,20 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\FormController; + +class ProfileController extends FormController +{ + protected $text_prefix = 'COM_MOKOBACKUP_PROFILE'; +} diff --git a/src/packages/com_mokobackup/src/Controller/ProfilesController.php b/src/packages/com_mokobackup/src/Controller/ProfilesController.php new file mode 100644 index 0000000..4c79e5f --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/ProfilesController.php @@ -0,0 +1,25 @@ + + * @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\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\AdminController; + +class ProfilesController extends AdminController +{ + protected $text_prefix = 'COM_MOKOBACKUP_PROFILES'; + + public function getModel($name = 'Profile', $prefix = 'Administrator', $config = ['ignore_request' => true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/src/packages/com_mokobackup/src/Controller/index.html b/src/packages/com_mokobackup/src/Controller/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/Controller/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php new file mode 100644 index 0000000..7c0826d --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -0,0 +1,187 @@ + + * @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; + +use Joomla\CMS\Factory; + +class BackupEngine +{ + private string $backupDir; + private array $log = []; + + /** + * Run a backup using the specified profile. + * + * @param int $profileId Profile ID to use + * @param string $description Human-readable description + * @param string $origin Origin: backend, cli, api, scheduled + * + * @return array{success: bool, message: string, record_id?: int} + */ + public function run(int $profileId, string $description, string $origin = 'backend'): array + { + $db = Factory::getDbo(); + + // Load profile + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokobackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + + if (!$profile) { + return ['success' => false, 'message' => 'Profile not found: ' . $profileId]; + } + + $config = json_decode($profile->config ?: '{}', true) ?: []; + $filters = json_decode($profile->filters ?: '{}', true) ?: []; + + // Determine backup directory + $this->backupDir = JPATH_ROOT . '/' . ($config['backup_dir'] ?? 'administrator/components/com_mokobackup/backups'); + + if (!is_dir($this->backupDir)) { + mkdir($this->backupDir, 0755, true); + } + + // Create backup record + $now = date('Y-m-d H:i:s'); + $tag = date('Ymd-His'); + $archiveName = 'site-' . $tag . '-profile' . $profileId . '.zip'; + + if (empty($description)) { + $description = $profile->title . ' — ' . $now; + } + + $record = (object) [ + 'profile_id' => $profileId, + 'description' => $description, + 'status' => 'running', + 'origin' => $origin, + 'backup_type' => $profile->backup_type, + 'archivename' => $archiveName, + 'absolute_path' => $this->backupDir . '/' . $archiveName, + 'total_size' => 0, + 'db_size' => 0, + 'files_count' => 0, + 'tables_count' => 0, + 'multipart' => 0, + 'tag' => $tag, + 'backupstart' => $now, + 'backupend' => '0000-00-00 00:00:00', + 'filesexist' => 0, + 'remote_filename' => '', + 'log' => '', + ]; + + $db->insertObject('#__mokobackup_records', $record, 'id'); + $recordId = $record->id; + + try { + $this->log('Backup started: ' . $description); + $archivePath = $this->backupDir . '/' . $archiveName; + + // Create ZIP archive + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create archive: ' . $archivePath); + } + + $dbSize = 0; + $filesCount = 0; + $tablesCount = 0; + + // Step 1: Database dump (unless files-only) + if ($profile->backup_type !== 'files') { + $this->log('Starting database dump...'); + $dumper = new DatabaseDumper($filters['exclude_tables'] ?? []); + $sqlDump = $dumper->dump(); + $zip->addFromString('database.sql', $sqlDump); + $dbSize = strlen($sqlDump); + $tablesCount = $dumper->getTablesCount(); + $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); + } + + // Step 2: Files (unless database-only) + if ($profile->backup_type !== 'database') { + $this->log('Starting file scan...'); + $scanner = new FileScanner( + JPATH_ROOT, + $filters['exclude_dirs'] ?? [], + $filters['exclude_files'] ?? [] + ); + + $files = $scanner->scan(); + $filesCount = count($files); + $this->log('Found ' . $filesCount . ' files to back up'); + + foreach ($files as $relativePath) { + $fullPath = JPATH_ROOT . '/' . $relativePath; + + if (is_file($fullPath) && is_readable($fullPath)) { + $zip->addFile($fullPath, $relativePath); + } + } + + $this->log('Files added to archive'); + } + + $zip->close(); + + // Update record with results + $totalSize = file_exists($archivePath) ? filesize($archivePath) : 0; + + $update = (object) [ + 'id' => $recordId, + 'status' => 'complete', + 'total_size' => $totalSize, + 'db_size' => $dbSize, + 'files_count' => $filesCount, + 'tables_count' => $tablesCount, + 'backupend' => date('Y-m-d H:i:s'), + 'filesexist' => 1, + 'log' => implode("\n", $this->log), + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + + $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; + $this->log('Backup complete: ' . $sizeHuman); + + return [ + 'success' => true, + 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', + 'record_id' => $recordId, + ]; + } catch (\Throwable $e) { + $this->log('FATAL: ' . $e->getMessage()); + + $update = (object) [ + 'id' => $recordId, + 'status' => 'fail', + 'backupend' => date('Y-m-d H:i:s'), + 'log' => implode("\n", $this->log), + ]; + + $db->updateObject('#__mokobackup_records', $update, 'id'); + + return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId]; + } + } + + private function log(string $message): void + { + $this->log[] = '[' . date('H:i:s') . '] ' . $message; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php new file mode 100644 index 0000000..3c81269 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php @@ -0,0 +1,155 @@ + + * @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; + +use Joomla\CMS\Factory; + +class DatabaseDumper +{ + private array $excludeTables; + private int $tablesCount = 0; + + /** + * @param array $excludeTables Table names to exclude (with #__ prefix) + */ + public function __construct(array $excludeTables = []) + { + $this->excludeTables = $excludeTables; + } + + /** + * Dump all database tables to SQL. + * + * @return string The SQL dump + */ + public function dump(): string + { + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + $output = []; + + $output[] = '-- MokoJoomBackup Database Dump'; + $output[] = '-- Generated: ' . date('Y-m-d H:i:s'); + $output[] = '-- Server: ' . $db->getServerType(); + $output[] = '-- Database: ' . $db->getName(); + $output[] = '-- Prefix: ' . $prefix; + $output[] = ''; + $output[] = 'SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";'; + $output[] = 'SET time_zone = "+00:00";'; + $output[] = ''; + + // Get all tables with the site prefix + $tables = $db->getTableList(); + $siteTables = []; + + foreach ($tables as $table) { + if (str_starts_with($table, $prefix)) { + $siteTables[] = $table; + } + } + + foreach ($siteTables as $table) { + // Check if excluded + $abstractName = '#__' . substr($table, strlen($prefix)); + + if ($this->isExcluded($abstractName, $table)) { + continue; + } + + $this->tablesCount++; + + // Get CREATE TABLE statement + $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); + $createRow = $db->loadRow(); + + if (!$createRow || empty($createRow[1])) { + continue; + } + + $output[] = '-- --------------------------------------------------------'; + $output[] = '-- Table: ' . $table; + $output[] = '-- --------------------------------------------------------'; + $output[] = ''; + $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; + $output[] = $createRow[1] . ';'; + $output[] = ''; + + // Dump data in chunks + $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); + $rowCount = (int) $db->loadResult(); + + if ($rowCount === 0) { + $output[] = '-- (empty table)'; + $output[] = ''; + continue; + } + + $chunkSize = 500; + + for ($offset = 0; $offset < $rowCount; $offset += $chunkSize) { + $db->setQuery( + $db->getQuery(true) + ->select('*') + ->from($db->quoteName($table)), + $offset, + $chunkSize + ); + $rows = $db->loadAssocList(); + + if (empty($rows)) { + break; + } + + foreach ($rows as $row) { + $values = []; + + foreach ($row as $value) { + if ($value === null) { + $values[] = 'NULL'; + } else { + $values[] = $db->quote($value); + } + } + + $columns = array_map([$db, 'quoteName'], array_keys($row)); + $output[] = 'INSERT INTO ' . $db->quoteName($table) + . ' (' . implode(', ', $columns) . ')' + . ' VALUES (' . implode(', ', $values) . ');'; + } + } + + $output[] = ''; + } + + return implode("\n", $output); + } + + /** + * Check if a table is excluded. + */ + private function isExcluded(string $abstractName, string $realName): bool + { + foreach ($this->excludeTables as $pattern) { + if ($pattern === $abstractName || $pattern === $realName) { + return true; + } + } + + return false; + } + + public function getTablesCount(): int + { + return $this->tablesCount; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/FileScanner.php b/src/packages/com_mokobackup/src/Engine/FileScanner.php new file mode 100644 index 0000000..aaa0577 --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/FileScanner.php @@ -0,0 +1,110 @@ + + * @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 FileScanner +{ + private string $rootDir; + private array $excludeDirs; + private array $excludeFiles; + + /** + * @param string $rootDir Root directory to scan + * @param array $excludeDirs Relative directory paths to exclude + * @param array $excludeFiles Filename patterns to exclude + */ + public function __construct(string $rootDir, array $excludeDirs = [], array $excludeFiles = []) + { + $this->rootDir = rtrim($rootDir, '/\\'); + $this->excludeDirs = array_map(fn($d) => trim($d, '/\\'), $excludeDirs); + $this->excludeFiles = $excludeFiles; + } + + /** + * Scan the root directory and return relative file paths. + * + * @return string[] Array of relative file paths + */ + public function scan(): array + { + $files = []; + $this->scanDirectory('', $files); + + return $files; + } + + private function scanDirectory(string $relativePath, array &$files): void + { + $fullPath = $this->rootDir . ($relativePath ? '/' . $relativePath : ''); + + if (!is_dir($fullPath) || !is_readable($fullPath)) { + return; + } + + $handle = opendir($fullPath); + + if ($handle === false) { + return; + } + + while (($entry = readdir($handle)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $entryRelative = $relativePath ? $relativePath . '/' . $entry : $entry; + $entryFull = $fullPath . '/' . $entry; + + if (is_dir($entryFull)) { + if (!$this->isDirExcluded($entryRelative)) { + $this->scanDirectory($entryRelative, $files); + } + } elseif (is_file($entryFull)) { + if (!$this->isFileExcluded($entry)) { + $files[] = $entryRelative; + } + } + } + + closedir($handle); + } + + private function isDirExcluded(string $relativePath): bool + { + $normalized = str_replace('\\', '/', $relativePath); + + foreach ($this->excludeDirs as $excluded) { + if ($normalized === $excluded || str_starts_with($normalized, $excluded . '/')) { + return true; + } + } + + // Always exclude .git + if (basename($relativePath) === '.git') { + return true; + } + + return false; + } + + private function isFileExcluded(string $filename): bool + { + foreach ($this->excludeFiles as $pattern) { + if ($filename === $pattern || fnmatch($pattern, $filename)) { + return true; + } + } + + return false; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/index.html b/src/packages/com_mokobackup/src/Engine/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/Engine/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php b/src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php new file mode 100644 index 0000000..a7a6ed9 --- /dev/null +++ b/src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php @@ -0,0 +1,19 @@ + + * @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\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\MVCComponent; + +class MokoBackupComponent extends MVCComponent +{ +} diff --git a/src/packages/com_mokobackup/src/Extension/index.html b/src/packages/com_mokobackup/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/Model/BackupModel.php b/src/packages/com_mokobackup/src/Model/BackupModel.php new file mode 100644 index 0000000..3379baa --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/BackupModel.php @@ -0,0 +1,46 @@ + + * @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\AdminModel; + +class BackupModel extends AdminModel +{ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokobackup.backup', + 'backup', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokobackup.edit.backup.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return $data; + } + + public function getTable($name = 'Backup', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/src/packages/com_mokobackup/src/Model/BackupsModel.php b/src/packages/com_mokobackup/src/Model/BackupsModel.php new file mode 100644 index 0000000..7b4d977 --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/BackupsModel.php @@ -0,0 +1,83 @@ + + * @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\MVC\Model\ListModel; +use Joomla\Database\QueryInterface; + +class BackupsModel extends ListModel +{ + public function __construct($config = []) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', 'a.id', + 'profile_id', 'a.profile_id', + 'status', 'a.status', + 'origin', 'a.origin', + 'backup_type', 'a.backup_type', + 'total_size', 'a.total_size', + 'backupstart', 'a.backupstart', + 'backupend', 'a.backupend', + ]; + } + + parent::__construct($config); + } + + protected function getListQuery(): QueryInterface + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokobackup_records', 'a')); + + // Join profile title + $query->select($db->quoteName('p.title', 'profile_title')) + ->join('LEFT', $db->quoteName('#__mokobackup_profiles', 'p') . ' ON p.id = a.profile_id'); + + // Filter by status + $status = $this->getState('filter.status'); + + if (!empty($status)) { + $query->where($db->quoteName('a.status') . ' = ' . $db->quote($status)); + } + + // Filter by profile + $profileId = $this->getState('filter.profile_id'); + + if (is_numeric($profileId)) { + $query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId); + } + + // Filter by search + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = $db->quote('%' . $db->escape(trim($search), true) . '%'); + $query->where('(' . $db->quoteName('a.description') . ' LIKE ' . $search . ')'); + } + + $orderCol = $this->state->get('list.ordering', 'a.backupstart'); + $orderDir = $this->state->get('list.direction', 'DESC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + + return $query; + } + + protected function populateState($ordering = 'a.backupstart', $direction = 'DESC'): void + { + parent::populateState($ordering, $direction); + } +} diff --git a/src/packages/com_mokobackup/src/Model/ProfileModel.php b/src/packages/com_mokobackup/src/Model/ProfileModel.php new file mode 100644 index 0000000..1935578 --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/ProfileModel.php @@ -0,0 +1,46 @@ + + * @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\AdminModel; + +class ProfileModel extends AdminModel +{ + public function getForm($data = [], $loadData = true) + { + $form = $this->loadForm( + 'com_mokobackup.profile', + 'profile', + ['control' => 'jform', 'load_data' => $loadData] + ); + + return $form ?: false; + } + + protected function loadFormData(): object + { + $data = Factory::getApplication()->getUserState('com_mokobackup.edit.profile.data', []); + + if (empty($data)) { + $data = $this->getItem(); + } + + return $data; + } + + public function getTable($name = 'Profile', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/src/packages/com_mokobackup/src/Model/ProfilesModel.php b/src/packages/com_mokobackup/src/Model/ProfilesModel.php new file mode 100644 index 0000000..0eecaff --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/ProfilesModel.php @@ -0,0 +1,67 @@ + + * @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\MVC\Model\ListModel; +use Joomla\Database\QueryInterface; + +class ProfilesModel extends ListModel +{ + public function __construct($config = []) + { + if (empty($config['filter_fields'])) { + $config['filter_fields'] = [ + 'id', 'a.id', + 'title', 'a.title', + 'backup_type', 'a.backup_type', + 'published', 'a.published', + 'ordering', 'a.ordering', + ]; + } + + parent::__construct($config); + } + + protected function getListQuery(): QueryInterface + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokobackup_profiles', 'a')); + + $published = $this->getState('filter.published'); + + if (is_numeric($published)) { + $query->where($db->quoteName('a.published') . ' = ' . (int) $published); + } + + $search = $this->getState('filter.search'); + + if (!empty($search)) { + $search = $db->quote('%' . $db->escape(trim($search), true) . '%'); + $query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')'); + } + + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + + return $query; + } + + protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void + { + parent::populateState($ordering, $direction); + } +} diff --git a/src/packages/com_mokobackup/src/Model/index.html b/src/packages/com_mokobackup/src/Model/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/Model/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/Table/BackupTable.php b/src/packages/com_mokobackup/src/Table/BackupTable.php new file mode 100644 index 0000000..9ea942e --- /dev/null +++ b/src/packages/com_mokobackup/src/Table/BackupTable.php @@ -0,0 +1,49 @@ + + * @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\Table; + +defined('_JEXEC') or die; + +use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseDriver; + +class BackupTable extends Table +{ + public function __construct(DatabaseDriver $db) + { + parent::__construct('#__mokobackup_records', 'id', $db); + } + + public function check(): bool + { + if (empty($this->profile_id)) { + $this->setError('Profile ID is required.'); + + return false; + } + + if (empty($this->backupstart) || $this->backupstart === '0000-00-00 00:00:00') { + $this->backupstart = date('Y-m-d H:i:s'); + } + + return true; + } + + public function delete($pk = null): bool + { + // Delete the archive file if it exists + if (!empty($this->absolute_path) && is_file($this->absolute_path)) { + @unlink($this->absolute_path); + } + + return parent::delete($pk); + } +} diff --git a/src/packages/com_mokobackup/src/Table/ProfileTable.php b/src/packages/com_mokobackup/src/Table/ProfileTable.php new file mode 100644 index 0000000..7155b0a --- /dev/null +++ b/src/packages/com_mokobackup/src/Table/ProfileTable.php @@ -0,0 +1,47 @@ + + * @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\Table; + +defined('_JEXEC') or die; + +use Joomla\CMS\Table\Table; +use Joomla\Database\DatabaseDriver; + +class ProfileTable extends Table +{ + public function __construct(DatabaseDriver $db) + { + parent::__construct('#__mokobackup_profiles', 'id', $db); + } + + public function check(): bool + { + if (empty($this->title)) { + $this->setError('Profile title is required.'); + + return false; + } + + if (empty($this->backup_type)) { + $this->backup_type = 'full'; + } + + $now = date('Y-m-d H:i:s'); + + if (empty($this->created) || $this->created === '0000-00-00 00:00:00') { + $this->created = $now; + } + + $this->modified = $now; + + return true; + } +} diff --git a/src/packages/com_mokobackup/src/Table/index.html b/src/packages/com_mokobackup/src/Table/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/Table/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/View/Backup/HtmlView.php b/src/packages/com_mokobackup/src/View/Backup/HtmlView.php new file mode 100644 index 0000000..15f25e0 --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Backup/HtmlView.php @@ -0,0 +1,39 @@ + + * @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\Backup; + +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 +{ + protected $item; + protected $form; + + public function display($tpl = null): void + { + $this->item = $this->get('Item'); + $this->form = $this->get('Form'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUP_DETAIL'), 'database'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokobackup&view=backups'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Backup/index.html b/src/packages/com_mokobackup/src/View/Backup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Backup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/View/Backups/HtmlView.php b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php new file mode 100644 index 0000000..088d5c1 --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Backups/HtmlView.php @@ -0,0 +1,47 @@ + + * @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\Backups; + +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 +{ + protected $items; + protected $pagination; + protected $state; + public $filterForm; + public $activeFilters = []; + + public function display($tpl = null): void + { + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUPS_TITLE'), 'database'); + ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOBACKUP_TOOLBAR_BACKUP_NOW', false); + ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); + ToolbarHelper::preferences('com_mokobackup'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Backups/index.html b/src/packages/com_mokobackup/src/View/Backups/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Backups/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/View/Profile/HtmlView.php b/src/packages/com_mokobackup/src/View/Profile/HtmlView.php new file mode 100644 index 0000000..ca959a8 --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Profile/HtmlView.php @@ -0,0 +1,44 @@ + + * @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\Profile; + +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 +{ + protected $item; + protected $form; + + public function display($tpl = null): void + { + $this->item = $this->get('Item'); + $this->form = $this->get('Form'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + $isNew = empty($this->item->id); + $title = $isNew ? 'COM_MOKOBACKUP_PROFILE_NEW' : 'COM_MOKOBACKUP_PROFILE_EDIT'; + + ToolbarHelper::title(Text::_($title), 'cog'); + ToolbarHelper::apply('profile.apply'); + ToolbarHelper::save('profile.save'); + ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Profile/index.html b/src/packages/com_mokobackup/src/View/Profile/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Profile/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/View/Profiles/HtmlView.php b/src/packages/com_mokobackup/src/View/Profiles/HtmlView.php new file mode 100644 index 0000000..4b11d8b --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Profiles/HtmlView.php @@ -0,0 +1,48 @@ + + * @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\Profiles; + +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 +{ + protected $items; + protected $pagination; + protected $state; + public $filterForm; + public $activeFilters = []; + + public function display($tpl = null): void + { + $this->items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOBACKUP_PROFILES_TITLE'), 'cog'); + ToolbarHelper::addNew('profile.add'); + ToolbarHelper::editList('profile.edit'); + ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'profiles.delete'); + ToolbarHelper::preferences('com_mokobackup'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Profiles/index.html b/src/packages/com_mokobackup/src/View/Profiles/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/View/Profiles/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/View/index.html b/src/packages/com_mokobackup/src/View/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/View/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/src/index.html b/src/packages/com_mokobackup/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/tmpl/backup/default.php b/src/packages/com_mokobackup/tmpl/backup/default.php new file mode 100644 index 0000000..22976e9 --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/backup/default.php @@ -0,0 +1,62 @@ + + * @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; + +?> +
+
+

escape($this->item->description); ?>

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
escape($this->item->status); ?>
escape($this->item->backup_type); ?>
escape($this->item->origin); ?>
item->total_size); ?>
item->backupstart, Text::_('DATE_FORMAT_LC2')); ?>
item->backupend, Text::_('DATE_FORMAT_LC2')); ?>
escape($this->item->archivename); ?>
item->files_count; ?>
item->tables_count; ?>
+
+
diff --git a/src/packages/com_mokobackup/tmpl/backup/index.html b/src/packages/com_mokobackup/tmpl/backup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/backup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/tmpl/backups/default.php b/src/packages/com_mokobackup/tmpl/backups/default.php new file mode 100644 index 0000000..65073bd --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/backups/default.php @@ -0,0 +1,131 @@ + + * @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\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + + + + items as $i => $item) : ?> + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ id); ?> + + escape($item->description); ?> + + escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?> + + status) { + 'complete' => 'badge bg-success', + 'running' => 'badge bg-info', + 'fail' => 'badge bg-danger', + default => 'badge bg-secondary', + }; + ?> + escape($item->status); ?> + + escape($item->backup_type); ?> + + total_size > 0) { + echo HTMLHelper::_('number.bytes', $item->total_size); + } else { + echo '—'; + } + ?> + + backupstart, Text::_('DATE_FORMAT_LC4')); ?> + + status === 'complete' && $item->filesexist) : ?> + + + + + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokobackup/tmpl/backups/index.html b/src/packages/com_mokobackup/tmpl/backups/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/backups/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/tmpl/index.html b/src/packages/com_mokobackup/tmpl/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/tmpl/profile/edit.php b/src/packages/com_mokobackup/tmpl/profile/edit.php new file mode 100644 index 0000000..1528ebb --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/profile/edit.php @@ -0,0 +1,50 @@ + + * @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; + +HTMLHelper::_('behavior.formvalidator'); +HTMLHelper::_('behavior.keepalive'); +?> +
+ +
+ 'general']); ?> + + +
+
+ form->renderFieldset('general'); ?> +
+
+ form->renderFieldset('sidebar'); ?> +
+
+ + + +
+
+ form->renderFieldset('filters'); ?> +
+
+ + + +
+ + + +
diff --git a/src/packages/com_mokobackup/tmpl/profile/index.html b/src/packages/com_mokobackup/tmpl/profile/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/profile/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/com_mokobackup/tmpl/profiles/default.php b/src/packages/com_mokobackup/tmpl/profiles/default.php new file mode 100644 index 0000000..431d893 --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/profiles/default.php @@ -0,0 +1,93 @@ + + * @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\Layout\LayoutHelper; +use Joomla\CMS\Router\Route; + +HTMLHelper::_('behavior.multiselect'); + +$listOrder = $this->escape($this->state->get('list.ordering')); +$listDirn = $this->escape($this->state->get('list.direction')); +?> +
+
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + items as $i => $item) : ?> + + + + + + + + + +
+ + + + + + + + + +
+ id); ?> + + + escape($item->title); ?> + + description)) : ?> +
escape($item->description); ?>
+ +
+ escape($item->backup_type); ?> + + published, $i, 'profiles.'); ?> + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/src/packages/com_mokobackup/tmpl/profiles/index.html b/src/packages/com_mokobackup/tmpl/profiles/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/com_mokobackup/tmpl/profiles/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/index.html b/src/packages/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/index.html b/src/packages/plg_system_mokobackup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/language/en-GB/index.html b/src/packages/plg_system_mokobackup/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.ini b/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.ini new file mode 100644 index 0000000..cdba55f --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — System Plugin language file (en-GB) +PLG_SYSTEM_MOKOBACKUP="System - MokoJoomBackup" +PLG_SYSTEM_MOKOBACKUP_DESCRIPTION="Automatic cleanup of expired backup archives and scheduled backup triggers." +PLG_SYSTEM_MOKOBACKUP_FIELD_AUTO_CLEANUP="Auto Cleanup" +PLG_SYSTEM_MOKOBACKUP_FIELD_AUTO_CLEANUP_DESC="Automatically remove old backup archives based on age and count limits." +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_AGE="Max Backup Age (days)" +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days." +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_BACKUPS="Max Backup Count" +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records." diff --git a/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.sys.ini b/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.sys.ini new file mode 100644 index 0000000..af5c9d2 --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-GB/plg_system_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — System Plugin system language file (en-GB) +PLG_SYSTEM_MOKOBACKUP="System - MokoJoomBackup" +PLG_SYSTEM_MOKOBACKUP_DESCRIPTION="Automatic cleanup of expired backup archives and scheduled backup triggers." diff --git a/src/packages/plg_system_mokobackup/language/en-US/index.html b/src/packages/plg_system_mokobackup/language/en-US/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-US/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.ini b/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.ini new file mode 100644 index 0000000..b9b8d5e --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.ini @@ -0,0 +1,9 @@ +; MokoJoomBackup — System Plugin language file (en-US) +PLG_SYSTEM_MOKOBACKUP="System - MokoJoomBackup" +PLG_SYSTEM_MOKOBACKUP_DESCRIPTION="Automatic cleanup of expired backup archives and scheduled backup triggers." +PLG_SYSTEM_MOKOBACKUP_FIELD_AUTO_CLEANUP="Auto Cleanup" +PLG_SYSTEM_MOKOBACKUP_FIELD_AUTO_CLEANUP_DESC="Automatically remove old backup archives based on age and count limits." +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_AGE="Max Backup Age (days)" +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_AGE_DESC="Delete backup records older than this many days." +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_BACKUPS="Max Backup Count" +PLG_SYSTEM_MOKOBACKUP_FIELD_MAX_BACKUPS_DESC="Keep at most this many completed backup records." diff --git a/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.sys.ini b/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.sys.ini new file mode 100644 index 0000000..c96a369 --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/en-US/plg_system_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — System Plugin system language file (en-US) +PLG_SYSTEM_MOKOBACKUP="System - MokoJoomBackup" +PLG_SYSTEM_MOKOBACKUP_DESCRIPTION="Automatic cleanup of expired backup archives and scheduled backup triggers." diff --git a/src/packages/plg_system_mokobackup/language/index.html b/src/packages/plg_system_mokobackup/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/mokobackup.php b/src/packages/plg_system_mokobackup/mokobackup.php new file mode 100644 index 0000000..9cabb97 --- /dev/null +++ b/src/packages/plg_system_mokobackup/mokobackup.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_system_mokobackup/mokobackup.xml b/src/packages/plg_system_mokobackup/mokobackup.xml new file mode 100644 index 0000000..1269f6b --- /dev/null +++ b/src/packages/plg_system_mokobackup/mokobackup.xml @@ -0,0 +1,68 @@ + + + + plg_system_mokobackup + 01.00.00-dev + 2026-06-02 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_SYSTEM_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\System\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_system_mokobackup.ini + language/en-GB/plg_system_mokobackup.sys.ini + + + + +
+ + + + + + +
+
+
+
diff --git a/src/packages/plg_system_mokobackup/services/index.html b/src/packages/plg_system_mokobackup/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/services/provider.php b/src/packages/plg_system_mokobackup/services/provider.php new file mode 100644 index 0000000..f0bf9f9 --- /dev/null +++ b/src/packages/plg_system_mokobackup/services/provider.php @@ -0,0 +1,37 @@ + + * @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\System\MokoBackup\Extension\MokoBackup; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackup( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('system', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_system_mokobackup/src/Extension/MokoBackup.php b/src/packages/plg_system_mokobackup/src/Extension/MokoBackup.php new file mode 100644 index 0000000..c166862 --- /dev/null +++ b/src/packages/plg_system_mokobackup/src/Extension/MokoBackup.php @@ -0,0 +1,124 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Plugin\System\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; + +final class MokoBackup extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onAfterRoute' => 'onAfterRoute', + ]; + } + + /** + * Cleanup expired backups on admin page loads (lightweight check). + */ + public function onAfterRoute(Event $event): void + { + $app = $this->getApplication(); + + // Only run in admin, and only on component page loads (not AJAX) + if (!$app->isClient('administrator') || $app->input->getCmd('format', 'html') !== 'html') { + return; + } + + if (!(int) $this->params->get('auto_cleanup', 1)) { + return; + } + + // Throttle: only check once per hour via session flag + $session = Factory::getSession(); + $lastCheck = $session->get('mokobackup.last_cleanup', 0); + + if (time() - $lastCheck < 3600) { + return; + } + + $session->set('mokobackup.last_cleanup', time()); + + $this->cleanupOldBackups(); + } + + /** + * Remove backup records and files older than max_age_days or exceeding max_backups. + */ + private function cleanupOldBackups(): void + { + $db = Factory::getDbo(); + $maxAge = (int) $this->params->get('max_age_days', 30); + $maxBackups = (int) $this->params->get('max_backups', 10); + + // Delete by age + $cutoff = date('Y-m-d H:i:s', strtotime("-{$maxAge} days")); + $query = $db->getQuery(true) + ->select('id, absolute_path') + ->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) { + 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(); + } + + // Enforce max backups count (keep newest) + $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 > $maxBackups) { + $excess = $totalCount - $maxBackups; + $query = $db->getQuery(true) + ->select('id, absolute_path') + ->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) { + 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(); + } + } + } +} diff --git a/src/packages/plg_system_mokobackup/src/Extension/index.html b/src/packages/plg_system_mokobackup/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_system_mokobackup/src/index.html b/src/packages/plg_system_mokobackup/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_system_mokobackup/src/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/index.html b/src/packages/plg_webservices_mokobackup/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/language/en-GB/index.html b/src/packages/plg_webservices_mokobackup/language/en-GB/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-GB/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.ini b/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.ini new file mode 100644 index 0000000..7038eca --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — WebServices Plugin language file (en-GB) +PLG_WEBSERVICES_MOKOBACKUP="Web Services - MokoJoomBackup" +PLG_WEBSERVICES_MOKOBACKUP_DESCRIPTION="REST API for remote backup management. Provides endpoints to start, list, download, and delete backups." diff --git a/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.sys.ini b/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.sys.ini new file mode 100644 index 0000000..3b2da03 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-GB/plg_webservices_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — WebServices Plugin system language file (en-GB) +PLG_WEBSERVICES_MOKOBACKUP="Web Services - MokoJoomBackup" +PLG_WEBSERVICES_MOKOBACKUP_DESCRIPTION="REST API for remote backup management." diff --git a/src/packages/plg_webservices_mokobackup/language/en-US/index.html b/src/packages/plg_webservices_mokobackup/language/en-US/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-US/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.ini b/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.ini new file mode 100644 index 0000000..e015f44 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — WebServices Plugin language file (en-US) +PLG_WEBSERVICES_MOKOBACKUP="Web Services - MokoJoomBackup" +PLG_WEBSERVICES_MOKOBACKUP_DESCRIPTION="REST API for remote backup management. Provides endpoints to start, list, download, and delete backups." diff --git a/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.sys.ini b/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.sys.ini new file mode 100644 index 0000000..c728408 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/en-US/plg_webservices_mokobackup.sys.ini @@ -0,0 +1,3 @@ +; MokoJoomBackup — WebServices Plugin system language file (en-US) +PLG_WEBSERVICES_MOKOBACKUP="Web Services - MokoJoomBackup" +PLG_WEBSERVICES_MOKOBACKUP_DESCRIPTION="REST API for remote backup management." diff --git a/src/packages/plg_webservices_mokobackup/language/index.html b/src/packages/plg_webservices_mokobackup/language/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/language/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/mokobackup.php b/src/packages/plg_webservices_mokobackup/mokobackup.php new file mode 100644 index 0000000..5a84f43 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/mokobackup.php @@ -0,0 +1,11 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; diff --git a/src/packages/plg_webservices_mokobackup/mokobackup.xml b/src/packages/plg_webservices_mokobackup/mokobackup.xml new file mode 100644 index 0000000..56f90ef --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/mokobackup.xml @@ -0,0 +1,32 @@ + + + + plg_webservices_mokobackup + 01.00.00-dev + 2026-06-02 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PLG_WEBSERVICES_MOKOBACKUP_DESCRIPTION + + Joomla\Plugin\WebServices\MokoBackup + + + mokobackup.php + services + src + + + + language/en-GB/plg_webservices_mokobackup.ini + language/en-GB/plg_webservices_mokobackup.sys.ini + + diff --git a/src/packages/plg_webservices_mokobackup/services/index.html b/src/packages/plg_webservices_mokobackup/services/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/services/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/services/provider.php b/src/packages/plg_webservices_mokobackup/services/provider.php new file mode 100644 index 0000000..96e07f1 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/services/provider.php @@ -0,0 +1,37 @@ + + * @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\WebServices\MokoBackup\Extension\MokoBackupWebServices; + +return new class () implements ServiceProviderInterface { + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $plugin = new MokoBackupWebServices( + $container->get(DispatcherInterface::class), + (array) PluginHelper::getPlugin('webservices', 'mokobackup') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_webservices_mokobackup/src/Extension/MokoBackupWebServices.php b/src/packages/plg_webservices_mokobackup/src/Extension/MokoBackupWebServices.php new file mode 100644 index 0000000..b6ad328 --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/src/Extension/MokoBackupWebServices.php @@ -0,0 +1,98 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * REST API endpoints — wire-compatible with the mcp_mokobackup MCP server. + * + * Akeeba-compatible routes: + * POST /api/index.php/v1/mokobackup/backup — Start backup + * GET /api/index.php/v1/mokobackup/backups — List records + * DELETE /api/index.php/v1/mokobackup/backup/:id — Delete record + * GET /api/index.php/v1/mokobackup/backup/:id/download — Download archive + * GET /api/index.php/v1/mokobackup/profiles — List profiles + */ + +namespace Joomla\Plugin\WebServices\MokoBackup\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Router\ApiRouter; +use Joomla\Event\Event; +use Joomla\Event\SubscriberInterface; +use Joomla\Router\Route; + +final class MokoBackupWebServices extends CMSPlugin implements SubscriberInterface +{ + protected $autoloadLanguage = true; + + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeApiRoute' => 'onBeforeApiRoute', + ]; + } + + public function onBeforeApiRoute(Event $event): void + { + /** @var ApiRouter $router */ + [$router] = array_values($event->getArguments()); + + $defaults = [ + 'component' => 'com_mokobackup', + 'public' => false, + ]; + + // Standard CRUD for backup records + $router->createCRUDRoutes('v1/mokobackup/backups', 'backups', $defaults); + + // Start a backup (POST) + $router->addRoute( + new Route( + ['POST'], + 'v1/mokobackup/backup', + 'backups.backup', + [], + $defaults + ) + ); + + // Delete a backup (DELETE) + $router->addRoute( + new Route( + ['DELETE'], + 'v1/mokobackup/backup/:id', + 'backups.delete', + ['id' => '(\d+)'], + $defaults + ) + ); + + // Download a backup archive (GET) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokobackup/backup/:id/download', + 'backups.download', + ['id' => '(\d+)'], + $defaults + ) + ); + + // List backup profiles (GET) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokobackup/profiles', + 'backups.profiles', + [], + $defaults + ) + ); + } +} diff --git a/src/packages/plg_webservices_mokobackup/src/Extension/index.html b/src/packages/plg_webservices_mokobackup/src/Extension/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/src/Extension/index.html @@ -0,0 +1 @@ + diff --git a/src/packages/plg_webservices_mokobackup/src/index.html b/src/packages/plg_webservices_mokobackup/src/index.html new file mode 100644 index 0000000..2efb97f --- /dev/null +++ b/src/packages/plg_webservices_mokobackup/src/index.html @@ -0,0 +1 @@ + diff --git a/src/pkg_mokobackup.xml b/src/pkg_mokobackup.xml new file mode 100644 index 0000000..5b770b7 --- /dev/null +++ b/src/pkg_mokobackup.xml @@ -0,0 +1,35 @@ + + + + Package - MokoJoomBackup + mokobackup + 01.00.00-dev + 2026-06-02 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + PKG_MOKOBACKUP_DESCRIPTION + + script.php + + + com_mokobackup.zip + plg_system_mokobackup.zip + plg_webservices_mokobackup.zip + + + + language/en-GB/pkg_mokobackup.sys.ini + + + + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/raw/branch/main/updates.xml + + diff --git a/src/script.php b/src/script.php new file mode 100644 index 0000000..93a94dc --- /dev/null +++ b/src/script.php @@ -0,0 +1,100 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Installer\InstallerAdapter; +use Joomla\CMS\Language\Text; + +class Pkg_MokoBackupInstallerScript +{ + /** + * Minimum Joomla version required + * + * @var string + */ + protected $minimumJoomla = '4.0.0'; + + /** + * Minimum PHP version required + * + * @var string + */ + protected $minimumPhp = '8.1.0'; + + /** + * Called before any install/update/uninstall action. + * + * @param string $type Action type (install, update, uninstall) + * @param InstallerAdapter $parent Installer adapter + * + * @return bool + */ + public function preflight(string $type, InstallerAdapter $parent): bool + { + if (version_compare(PHP_VERSION, $this->minimumPhp, '<')) { + Factory::getApplication()->enqueueMessage( + Text::sprintf('PKG_MOKOBACKUP_PHP_VERSION_ERROR', $this->minimumPhp), + 'error' + ); + + return false; + } + + return true; + } + + /** + * Called after install/update. + * + * @param string $type Action type + * @param InstallerAdapter $parent Installer adapter + * + * @return void + */ + public function postflight(string $type, InstallerAdapter $parent): void + { + if ($type === 'install') { + // Enable the system plugin automatically on fresh install + $db = Factory::getDbo(); + $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('system')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + + // Enable the webservices 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('webservices')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mokobackup')); + + $db->setQuery($query); + $db->execute(); + + // Create default backup directory + $backupDir = JPATH_ADMINISTRATOR . '/components/com_mokobackup/backups'; + + if (!is_dir($backupDir)) { + mkdir($backupDir, 0755, true); + + // Protect backup directory with .htaccess + file_put_contents($backupDir . '/.htaccess', "Order deny,allow\nDeny from all\n"); + file_put_contents($backupDir . '/index.html', ''); + } + } + } +} diff --git a/updates.xml b/updates.xml new file mode 100644 index 0000000..5ba8cf1 --- /dev/null +++ b/updates.xml @@ -0,0 +1,15 @@ + + + + Package - MokoJoomBackup + Full-site backup and restore for Joomla + mokobackup + package + 01.00.00 + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/tag/v01.00.00 + https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases/download/v01.00.00/pkg_mokobackup-01.00.00.zip + + + 8.1.0 + +