commit b82c1f8a244418be5e1de7e3f136eb99b6e8190e Author: Jonathan Miller Date: Tue Jun 2 13:47:36 2026 -0500 feat: initial MokoJoomBackup package — Akeeba Backup Pro replacement 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) 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 + +