diff --git a/.gitignore b/.gitignore index 4abd225..4881dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -151,7 +151,7 @@ package-lock.json # PHP / Composer tooling # ============================================================ vendor/ -!src/media/vendor/ +!source/media/vendor/ composer.lock *.phar codeception.phar diff --git a/.mokogitea/CLAUDE.md b/.mokogitea/CLAUDE.md new file mode 100644 index 0000000..c5dda15 --- /dev/null +++ b/.mokogitea/CLAUDE.md @@ -0,0 +1,73 @@ +# MokoJoomBackup + +Full-site backup and restore for Joomla — database, files, and configuration. Replaces Akeeba Backup Pro. + +## Quick Reference + +| Field | Value | +|---|---| +| **Package** | `pkg_mokojoombackup` | +| **Language** | PHP 8.1+ | +| **Branch** | develop on `dev`, merge to `main` (protected) | +| **Wiki** | [MokoJoomBackup Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/wiki) | + +## Commands + +```bash +make build # Build package ZIP +make lint # Run linters +make validate # Validate structure +make release # Full release pipeline +make clean # Clean build artifacts +composer install # Install PHP dependencies +``` + +## Architecture + +Joomla **package** with four sub-extensions: + +### com_mokojoombackup (Component) +- Admin backend for managing backup profiles and records +- Backup engine: `Engine/BackupEngine`, `Engine/DatabaseDumper`, `Engine/FileScanner`, `Engine/Archiver` +- Joomla 4/5 MVC: Controllers, Models, Views, Tables +- Namespace: `Joomla\Component\MokoJoomBackup\Administrator` +- DB tables: `#__mokojoombackup_profiles`, `#__mokojoombackup_records` +- CLI: `cli/mokojoombackup.php` for cron-based backups + +### plg_system_mokojoombackup (System Plugin) +- Cleanup of expired backup archives (age + count limits) +- Namespace: `Joomla\Plugin\System\MokoJoomBackup` + +### plg_task_mokojoombackup (Task Plugin) +- Integrates with Joomla's Scheduled Tasks (com_scheduler) +- Registers "Run Backup Profile" task type +- Namespace: `Joomla\Plugin\Task\MokoJoomBackup` + +### plg_webservices_mokojoombackup (WebServices Plugin) +- REST API for remote backup management (wire-compatible with mcp_mokojoombackup) +- Endpoints: backup, backups, profiles, download, delete +- Namespace: `Joomla\Plugin\WebServices\MokoJoomBackup` + +### Database Schema + +- `#__mokojoombackup_profiles` — backup profiles (name, description, config JSON, filters JSON) +- `#__mokojoombackup_records` — backup records (profile_id, status, origin, archive path, sizes, timestamps) + +## Rules + +- **Never commit** `.claude/`, `.mcp.json`, `TODO.md`, `*.min.css`/`*.min.js` +- **Attribution**: `Authored-by: Moko Consulting` +- **Workflow directory**: `.mokogitea/` (not `.gitea/` or `.github/`) +- **Minification**: handled at build time (CI) +- **Wiki**: documentation lives in the Gitea wiki, not `docs/` files +- **Standards**: [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/.mokogitea/manifest.xml b/.mokogitea/manifest.xml index 41e81ee..3cc8e77 100644 --- a/.mokogitea/manifest.xml +++ b/.mokogitea/manifest.xml @@ -5,7 +5,7 @@ Package - MokoJoomBackup MokoConsulting Full-site backup and restore for Joomla — database, files, and configuration - 01.00.00 + 01.01.21-dev GNU General Public License v3 @@ -16,6 +16,6 @@ PHP joomla-extension - src/ + source/ diff --git a/.mokogitea/workflows/auto-bump.yml b/.mokogitea/workflows/auto-bump.yml new file mode 100644 index 0000000..33aff71 --- /dev/null +++ b/.mokogitea/workflows/auto-bump.yml @@ -0,0 +1,66 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.mokogitea/workflows/auto-bump.yml +# VERSION: 09.23.00 +# BRIEF: Auto patch-bump version on every push to dev (skips merge commits) + +name: "Universal: Auto Version Bump" + +on: + push: + branches: + - dev + - rc + - 'feature/**' + - 'patch/**' + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +permissions: + contents: write + +jobs: + bump: + name: Version Bump + runs-on: release + if: >- + !contains(github.event.head_commit.message, '[skip ci]') && + !contains(github.event.head_commit.message, '[skip bump]') && + !startsWith(github.event.head_commit.message, 'Merge pull request') + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup moko-platform tools + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + if [ -d "/opt/moko-platform/cli" ]; then + echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV" + else + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet + echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV" + fi + + - name: Bump version + run: | + php ${MOKO_CLI}/version_auto_bump.php \ + --path . --branch "${GITHUB_REF_NAME}" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 44a2d64..141fdcc 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -1,285 +1,285 @@ -# Copyright (C) 2026 Moko Consulting -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Gitea.Workflow -# INGROUP: moko-platform.Release -# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform -# PATH: /templates/workflows/universal/auto-release.yml.template -# VERSION: 05.00.00 -# BRIEF: Universal build & release � detects platform from manifest.xml -# -# +========================================================================+ -# | UNIVERSAL BUILD & RELEASE PIPELINE | -# +========================================================================+ -# | | -# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | -# | | -# | Platform-specific: | -# | joomla: XML manifest, updates.xml, type-prefixed packages | -# | dolibarr: mod*.class.php, update.txt, dev version reset | -# | generic: README-only, no update stream | -# | | -# +========================================================================+ - -name: "Universal: Build & Release" - -on: - pull_request: - types: [opened, closed] - branches: - - main - workflow_dispatch: - inputs: - action: - description: 'Action to perform' - required: false - type: choice - default: release - options: - - release - - promote-rc - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} - GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} - -permissions: - contents: write - -jobs: - # ── PR Opened → Rename branch to RC and build RC release ───────────────────── - promote-rc: - name: Promote to RC - runs-on: release - if: >- - (github.event.action == 'opened' && github.event.pull_request.merged != true) || - (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 1 - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - run: | - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - # Always fetch latest CLI tools — never use stale cache from previous runs - rm -rf /tmp/moko-platform-api - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - name: Rename branch to rc - run: | - php /tmp/moko-platform-api/cli/branch_rename.php \ - --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ - --pr "${{ github.event.pull_request.number }}" - - - name: Checkout rc and configure git - run: | - git fetch origin rc - git checkout rc - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - - - name: Publish RC release - run: | - php /tmp/moko-platform-api/cli/release_publish.php \ - --path . --stability rc --bump minor --branch rc \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --skip-update-stream - - - name: Summary - if: always() - run: | - echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY - echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY - - # ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── - release: - name: Build & Release Pipeline - runs-on: release - if: >- - github.event.pull_request.merged == true || - (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ secrets.MOKOGITEA_TOKEN }} - fetch-depth: 0 - - - name: Configure git for bot pushes - run: | - git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" - git config --local user.name "gitea-actions[bot]" - git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" - - - name: Check for merge conflict markers - run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) - if [ -n "$CONFLICTS" ]; then - echo "::error::Merge conflict markers found — aborting release" - echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "No conflict markers found" - - - name: Setup moko-platform tools - env: - MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' - run: | - # Ensure PHP + Composer are available - if ! command -v composer &> /dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 - fi - # Always fetch latest CLI tools — never use stale cache from previous runs - rm -rf /tmp/moko-platform-api - git clone --depth 1 --branch main --quiet \ - "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ - /tmp/moko-platform-api - cd /tmp/moko-platform-api - composer install --no-dev --no-interaction --quiet - - - - name: "Publish stable release" - run: | - php /tmp/moko-platform-api/cli/release_publish.php \ - --path . --stability stable --bump minor --branch main \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" \ - --skip-update-stream - - # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- - - name: "Step 9: Mirror release to GitHub" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_MIRROR_TOKEN != '' - continue-on-error: true - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - RELEASE_TAG="${{ steps.version.outputs.release_tag }}" - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/release_mirror.php \ - --version "$VERSION" --tag "$RELEASE_TAG" \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ - --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ - --branch main 2>&1 || true - echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY - - # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- - - name: "Step 10: Push main to GitHub mirror" - if: >- - steps.version.outputs.skip != 'true' && - secrets.GH_MIRROR_TOKEN != '' - continue-on-error: true - run: | - GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" - GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) - GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) - git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ - git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" - git fetch origin main --depth=1 - git push github origin/main:refs/heads/main --force 2>/dev/null \ - && echo "main branch pushed to GitHub mirror" \ - || echo "WARNING: GitHub mirror push failed" - - - name: "Step 11: Delete rc branch and recreate dev from main" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - - # Delete rc branch (ephemeral — created by promote-rc) - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/branches/rc" 2>/dev/null \ - && echo "Deleted rc branch" || echo "rc branch not found" - - # Delete dev branch - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ - "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" - - # Recreate dev from main (now includes version bump + changelog promotion) - curl -sf -X POST -H "Authorization: token ${TOKEN}" \ - -H "Content-Type: application/json" \ - "${API_BASE}/branches" \ - -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" - - echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY - - - name: "Step 12: Create version branch from main" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - BRANCH_NAME="version/${VERSION}" - MAIN_SHA=$(git rev-parse HEAD) - - # Delete old version branch if it exists (same version re-release) - curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" - - # Create version/XX.YY.ZZ from main - curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" - - echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY - - - - # -- Dolibarr post-release: Reset dev version ----------------------------- - - name: "Post-release: Reset dev version" - if: steps.version.outputs.skip != 'true' - continue-on-error: true - run: | - API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" - php /tmp/moko-platform-api/cli/version_reset_dev.php \ - --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ - --branch dev --path . 2>&1 || true - - # -- Summary -------------------------------------------------------------- - - name: Pipeline Summary - if: always() - run: | - VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" - PLATFORM="${{ steps.platform.outputs.platform }}" - if [ "${{ steps.version.outputs.skip }}" = "true" ]; then - echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY - echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY - elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then - echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY - echo "|------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY - fi +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform +# PATH: /templates/workflows/universal/auto-release.yml.template +# VERSION: 05.00.00 +# BRIEF: Universal build & release � detects platform from manifest.xml +# +# +========================================================================+ +# | UNIVERSAL BUILD & RELEASE PIPELINE | +# +========================================================================+ +# | | +# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | +# | | +# | Platform-specific: | +# | joomla: XML manifest, updates.xml, type-prefixed packages | +# | dolibarr: mod*.class.php, update.txt, dev version reset | +# | generic: README-only, no update stream | +# | | +# +========================================================================+ + +name: "Universal: Build & Release" + +on: + pull_request: + types: [opened, closed] + branches: + - main + workflow_dispatch: + inputs: + action: + description: 'Action to perform' + required: false + type: choice + default: release + options: + - release + - promote-rc + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +permissions: + contents: write + +jobs: + # ── PR Opened → Rename branch to RC and build RC release ───────────────────── + promote-rc: + name: Promote to RC + runs-on: release + if: >- + (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 1 + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + # Always fetch latest CLI tools — never use stale cache from previous runs + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + + - name: Rename branch to rc + run: | + php /tmp/moko-platform-api/cli/branch_rename.php \ + --from "${{ github.event.pull_request.head.ref || 'dev' }}" --to rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --api-base "${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" \ + --pr "${{ github.event.pull_request.number }}" + + - name: Checkout rc and configure git + run: | + git fetch origin rc + git checkout rc + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Publish RC release + run: | + php /tmp/moko-platform-api/cli/release_publish.php \ + --path . --stability rc --bump minor --branch rc \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + - name: Summary + if: always() + run: | + echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY + echo "Branch renamed to rc, minor bump, RC release built (updates.xml managed by Gitea Pages)" >> $GITHUB_STEP_SUMMARY + + # ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── + release: + name: Build & Release Pipeline + runs-on: release + if: >- + github.event.pull_request.merged == true || + (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc') + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + fetch-depth: 0 + + - name: Configure git for bot pushes + run: | + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + + - name: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found — aborting release" + echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_MIRROR_TOKEN }}"}}' + run: | + # Ensure PHP + Composer are available + if ! command -v composer &> /dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 + fi + # Always fetch latest CLI tools — never use stale cache from previous runs + rm -rf /tmp/moko-platform-api + git clone --depth 1 --branch main --quiet \ + "https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git" \ + /tmp/moko-platform-api + cd /tmp/moko-platform-api + composer install --no-dev --no-interaction --quiet + + + - name: "Publish stable release" + run: | + php /tmp/moko-platform-api/cli/release_publish.php \ + --path . --stability stable --bump minor --branch main \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --skip-update-stream + + # -- STEP 9: Mirror to GitHub (stable only) -------------------------------- + - name: "Step 9: Mirror release to GitHub" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + RELEASE_TAG="${{ steps.version.outputs.release_tag }}" + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php /tmp/moko-platform-api/cli/release_mirror.php \ + --version "$VERSION" --tag "$RELEASE_TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --gh-token "${{ secrets.GH_MIRROR_TOKEN }}" --gh-repo "$GH_REPO" \ + --branch main 2>&1 || true + echo "GitHub mirror updated" >> $GITHUB_STEP_SUMMARY + + # -- STEP 10: Sync main branch to GitHub mirror ---------------------------- + - name: "Step 10: Push main to GitHub mirror" + if: >- + steps.version.outputs.skip != 'true' && + secrets.GH_MIRROR_TOKEN != '' + continue-on-error: true + run: | + GH_REPO="${{ vars.GH_MIRROR_REPO || github.repository }}" + GH_ORG=$(echo "$GH_REPO" | cut -d/ -f1) + GH_NAME=$(echo "$GH_REPO" | cut -d/ -f2) + git remote add github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" 2>/dev/null || \ + git remote set-url github "https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${GH_ORG}/${GH_NAME}.git" + git fetch origin main --depth=1 + git push github origin/main:refs/heads/main --force 2>/dev/null \ + && echo "main branch pushed to GitHub mirror" \ + || echo "WARNING: GitHub mirror push failed" + + - name: "Step 11: Delete rc branch and recreate dev from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + # Delete rc branch (ephemeral — created by promote-rc) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/rc" 2>/dev/null \ + && echo "Deleted rc branch" || echo "rc branch not found" + + # Delete dev branch + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \ + "${API_BASE}/branches/dev" 2>/dev/null && echo "Deleted dev branch" + + # Recreate dev from main (now includes version bump + changelog promotion) + curl -sf -X POST -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API_BASE}/branches" \ + -d '{"new_branch_name":"dev","old_branch_name":"main"}' 2>/dev/null && echo "Recreated dev from main" + + echo "Pre-release branches cleaned, dev reset from main" >> $GITHUB_STEP_SUMMARY + + - name: "Step 12: Create version branch from main" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + BRANCH_NAME="version/${VERSION}" + MAIN_SHA=$(git rev-parse HEAD) + + # Delete old version branch if it exists (same version re-release) + curl -sf -X DELETE -H "Authorization: token ${TOKEN}" "${API_BASE}/branches/${BRANCH_NAME}" 2>/dev/null && echo "Deleted old ${BRANCH_NAME}" + + # Create version/XX.YY.ZZ from main + curl -sf -X POST -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" "${API_BASE}/branches" -d "{\"new_branch_name\":\"${BRANCH_NAME}\",\"old_branch_name\":\"main\"}" 2>/dev/null && echo "Created ${BRANCH_NAME} from main (${MAIN_SHA})" || echo "WARNING: ${BRANCH_NAME} creation failed" + + echo "Version branch created: ${BRANCH_NAME} (${MAIN_SHA})" >> $GITHUB_STEP_SUMMARY + + + + # -- Dolibarr post-release: Reset dev version ----------------------------- + - name: "Post-release: Reset dev version" + if: steps.version.outputs.skip != 'true' + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php /tmp/moko-platform-api/cli/version_reset_dev.php \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "${API_BASE}" \ + --branch dev --path . 2>&1 || true + + # -- Summary -------------------------------------------------------------- + - name: Pipeline Summary + if: always() + run: | + VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}" + PLATFORM="${{ steps.platform.outputs.platform }}" + if [ "${{ steps.version.outputs.skip }}" = "true" ]; then + echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY + echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY + elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then + echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "## Build & Release Complete (${PLATFORM})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY + echo "|------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Release | [View](${GITEA_URL}/${GITEA_ORG}/${GITEA_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.mokogitea/workflows/branch-cleanup.yml b/.mokogitea/workflows/branch-cleanup.yml new file mode 100644 index 0000000..67a735f --- /dev/null +++ b/.mokogitea/workflows/branch-cleanup.yml @@ -0,0 +1,48 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: MokoPlatform.Universal +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /.mokogitea/workflows/branch-cleanup.yml +# VERSION: 09.23.00 +# BRIEF: Delete feature branches after PR merge + +name: "Branch Cleanup" + +on: + pull_request: + types: [closed] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + cleanup: + name: Delete merged branch + runs-on: ubuntu-latest + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.ref != 'dev' && + github.event.pull_request.head.ref != 'main' + + steps: + - name: Delete source branch + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches" + ENCODED=$(php -r "echo rawurlencode('${BRANCH}');") + + STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API}/${ENCODED}" 2>/dev/null || true) + + if [ "$STATUS" = "204" ]; then + echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + elif [ "$STATUS" = "404" ]; then + echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY + else + echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})" + fi diff --git a/.mokogitea/workflows/ci-joomla.yml b/.mokogitea/workflows/ci-joomla.yml index f679e86..e67987b 100644 --- a/.mokogitea/workflows/ci-joomla.yml +++ b/.mokogitea/workflows/ci-joomla.yml @@ -67,7 +67,7 @@ jobs: - name: PHP syntax check run: | ERRORS=0 - for DIR in src/ htdocs/; do + for DIR in source/ src/ htdocs/; do if [ -d "$DIR" ]; then FOUND=1 while IFS= read -r -d '' FILE; do @@ -207,7 +207,7 @@ jobs: echo "### Language Directory Check" >> $GITHUB_STEP_SUMMARY ERRORS=0 - for DIR in src/ htdocs/; do + for DIR in source/ src/ htdocs/; do [ -d "$DIR" ] || continue # Find all language directories while IFS= read -r -d '' LANG_DIR; do @@ -239,7 +239,7 @@ jobs: MISSING=0 CHECKED=0 - for DIR in src/ htdocs/; do + for DIR in source/ src/ htdocs/; do if [ -d "$DIR" ]; then while IFS= read -r -d '' SUBDIR; do CHECKED=$((CHECKED + 1)) @@ -252,7 +252,7 @@ jobs: done if [ "${CHECKED}" -eq 0 ]; then - echo "No src/ or htdocs/ directories found — skipping." >> $GITHUB_STEP_SUMMARY + echo "No source/, 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 @@ -450,7 +450,7 @@ jobs: # Determine source directory SRC_DIR="" - for DIR in src/ htdocs/ lib/; do + for DIR in source/ src/ htdocs/ lib/; do if [ -d "$DIR" ]; then SRC_DIR="$DIR" break @@ -458,7 +458,7 @@ jobs: done if [ -z "$SRC_DIR" ]; then - echo "No source directory found (src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY + echo "No source directory found (source/, src/, htdocs/, lib/) — skipping." >> $GITHUB_STEP_SUMMARY exit 0 fi diff --git a/.mokogitea/workflows/cleanup.yml b/.mokogitea/workflows/cleanup.yml index 29ca4d4..70521b3 100644 --- a/.mokogitea/workflows/cleanup.yml +++ b/.mokogitea/workflows/cleanup.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Maintenance # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/cleanup.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Scheduled cleanup — delete merged branches and old workflow runs name: "Universal: Repository Cleanup" diff --git a/.mokogitea/workflows/gitleaks.yml b/.mokogitea/workflows/gitleaks.yml index e0fdd1d..9126c91 100644 --- a/.mokogitea/workflows/gitleaks.yml +++ b/.mokogitea/workflows/gitleaks.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Security # REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/moko-platform # PATH: /templates/workflows/gitleaks.yml.template -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens # # +========================================================================+ diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml new file mode 100644 index 0000000..7eadb11 --- /dev/null +++ b/.mokogitea/workflows/issue-branch.yml @@ -0,0 +1,73 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Automation +# VERSION: 01.01.21 +# BRIEF: Auto-create feature branch when an issue is opened + +name: "Universal: Issue Branch" + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + +jobs: + create-branch: + name: Create feature branch + runs-on: ubuntu-latest + steps: + - name: Create branch and comment + run: | + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + API="${GITEA_URL}/api/v1/repos/${{ github.repository }}" + ISSUE_NUM="${{ github.event.issue.number }}" + ISSUE_TITLE="${{ github.event.issue.title }}" + + # Build slug from title: lowercase, replace non-alnum with dash, trim + SLUG=$(echo "${ISSUE_TITLE}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | cut -c1-40) + BRANCH="feature/${ISSUE_NUM}-${SLUG}" + + # Check dev branch exists + DEV_EXISTS=$(curl -sf -o /dev/null -w '%{http_code}' \ + -H "Authorization: token ${TOKEN}" \ + "${API}/branches/dev" 2>/dev/null || echo "000") + + if [ "${DEV_EXISTS}" != "200" ]; then + echo "No dev branch -- skipping" + exit 0 + fi + + # Create branch from dev + HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/branches" \ + -d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"dev\"}" 2>/dev/null || echo "000") + + if [ "${HTTP}" = "201" ]; then + echo "Created branch: ${BRANCH}" + + # Comment on issue with branch link + REPO_URL="${GITEA_URL}/${{ github.repository }}" + BODY="Branch created: [\`${BRANCH}\`](${REPO_URL}/src/branch/${BRANCH})\n\n\`\`\`bash\ngit fetch origin\ngit checkout ${BRANCH}\n\`\`\`" + + curl -sf -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/issues/${ISSUE_NUM}/comments" \ + -d "{\"body\":\"${BODY}\"}" > /dev/null 2>&1 + + echo "Commented on issue #${ISSUE_NUM}" + else + echo "Failed to create branch (HTTP ${HTTP}) -- may already exist" + fi diff --git a/.mokogitea/workflows/notify.yml b/.mokogitea/workflows/notify.yml index cde4541..c18b809 100644 --- a/.mokogitea/workflows/notify.yml +++ b/.mokogitea/workflows/notify.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Notifications # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/notify.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Push notifications via ntfy on release success or workflow failure name: "Universal: Notifications" diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index 4d78d7a..6625857 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -1,508 +1,508 @@ -# 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: 09.23.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: Check for merge conflict markers - run: | - CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) - if [ -n "$CONFLICTS" ]; then - echo "::error::Merge conflict markers found in source files" - echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "No conflict markers found" - - - 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: Joomla JEXEC guard check - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - while IFS= read -r -d '' file; do - # Skip vendor, node_modules, and index.html stub files - case "$file" in ./vendor/*|./node_modules/*) continue ;; esac - # Check first 10 lines for JEXEC or JPATH guard - if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then - echo "::error file=${file}::Missing JEXEC guard: ${file}" - ERRORS=$((ERRORS + 1)) - fi - done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) - if [ "$ERRORS" -gt 0 ]; then - echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" - echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY - echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY - exit 1 - fi - echo "JEXEC guard: OK" - - - name: Joomla directory listing protection - if: steps.platform.outputs.platform == 'joomla' - run: | - MISSING=0 - SOURCE_DIR="src" - [ ! -d "$SOURCE_DIR" ] && exit 0 - while IFS= read -r dir; do - if [ ! -f "${dir}/index.html" ]; then - echo "::warning::Missing index.html in ${dir} (directory listing protection)" - MISSING=$((MISSING + 1)) - fi - done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") - if [ "$MISSING" -gt 0 ]; then - echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY - echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY - fi - echo "Directory protection: ${MISSING} missing (advisory)" - - - name: Joomla script file and asset checks - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) - [ -z "$MANIFEST" ] && exit 0 - MANIFEST_DIR=$(dirname "$MANIFEST") - - # Check scriptfile exists if declared - SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) - if [ -n "$SCRIPTFILE" ]; then - if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then - echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" - ERRORS=$((ERRORS + 1)) - else - echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" - fi - fi - - # Require joomla.asset.json and validate it - ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$ASSET_JSON" ]; then - echo "::error::joomla.asset.json not found — Joomla asset system is required" - ERRORS=$((ERRORS + 1)) - else - if command -v php &> /dev/null; then - php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { - echo "::error::joomla.asset.json is not valid JSON" - ERRORS=$((ERRORS + 1)) - } - fi - echo "joomla.asset.json: valid" - fi - - # Validate all XML files in src/ are well-formed - XML_ERRORS=0 - if command -v php &> /dev/null; then - while IFS= read -r -d '' xmlfile; do - if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then - XML_ERRORS=$((XML_ERRORS + 1)) - fi - done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) - fi - if [ "$XML_ERRORS" -gt 0 ]; then - echo "::error::${XML_ERRORS} XML file(s) are malformed" - ERRORS=$((ERRORS + 1)) - else - echo "XML well-formedness: OK" - fi - - [ "$ERRORS" -gt 0 ] && exit 1 - echo "Joomla asset checks: OK" - - - 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 - # Block legacy raw/branch update server URLs on MokoGitea - RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) - if [ -n "$RAW_URLS" ]; then - echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" - echo "$RAW_URLS" - exit 1 - fi - 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: Validate Joomla language files - if: steps.platform.outputs.platform == 'joomla' - run: | - ERRORS=0 - WARNINGS=0 - - # Require both en-GB and en-US language directories - LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) - if [ -z "$LANG_ROOT" ]; then - echo "No language/ directory found — skipping" - exit 0 - fi - - if [ ! -d "$LANG_ROOT/en-GB" ]; then - echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" - ERRORS=$((ERRORS + 1)) - fi - if [ ! -d "$LANG_ROOT/en-US" ]; then - echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" - ERRORS=$((ERRORS + 1)) - fi - - # Check that en-GB and en-US have matching .ini files - if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then - for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do - [ ! -f "$GB_INI" ] && continue - US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" - if [ ! -f "$US_INI" ]; then - echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" - ERRORS=$((ERRORS + 1)) - fi - done - for US_INI in "$LANG_ROOT/en-US"/*.ini; do - [ ! -f "$US_INI" ] && continue - GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" - if [ ! -f "$GB_INI" ]; then - echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" - ERRORS=$((ERRORS + 1)) - fi - done - fi - - # Find all .ini language files - INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) - if [ -z "$INI_FILES" ]; then - echo "No .ini language files found" - [ "$ERRORS" -gt 0 ] && exit 1 - exit 0 - fi - - echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" - - for FILE in $INI_FILES; do - FNAME=$(basename "$FILE") - LINENUM=0 - SEEN_KEYS="" - - while IFS= read -r line || [ -n "$line" ]; do - LINENUM=$((LINENUM + 1)) - - # Skip empty lines and comments - [ -z "$line" ] && continue - echo "$line" | grep -qE '^\s*;' && continue - echo "$line" | grep -qE '^\s*$' && continue - - # Must match KEY="VALUE" format - if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then - echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" - ERRORS=$((ERRORS + 1)) - continue - fi - - # Extract key and check for duplicates - KEY=$(echo "$line" | sed 's/=.*//') - if echo "$SEEN_KEYS" | grep -qx "$KEY"; then - echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" - ERRORS=$((ERRORS + 1)) - fi - SEEN_KEYS="${SEEN_KEYS} - ${KEY}" - done < "$FILE" - - echo " ${FILE}: checked ${LINENUM} lines" - done - - # Cross-check en-GB vs en-US key consistency - GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) - US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) - - if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then - for GB_FILE in "$GB_DIR"/*.ini; do - [ ! -f "$GB_FILE" ] && continue - FNAME=$(basename "$GB_FILE") - US_FILE="$US_DIR/$FNAME" - [ ! -f "$US_FILE" ] && continue - - GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) - US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) - - # Keys in en-GB but not en-US - MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_US" ]; then - echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" - echo "$MISSING_US" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - - # Keys in en-US but not en-GB - MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) - if [ -n "$MISSING_GB" ]; then - echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" - echo "$MISSING_GB" | while read -r k; do echo " - $k"; done - WARNINGS=$((WARNINGS + 1)) - fi - done - fi - - { - echo "### Language File Validation" - echo "| Metric | Count |" - echo "|---|---|" - echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" - echo "| Errors | ${ERRORS} |" - echo "| Warnings | ${WARNINGS} |" - } >> $GITHUB_STEP_SUMMARY - - if [ "$ERRORS" -gt 0 ]; then - echo "::error::Language validation failed with ${ERRORS} error(s)" - exit 1 - fi - echo "Language files: OK (${WARNINGS} warning(s))" - - - 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; } - - # ── Pre-Release RC Build ───────────────────────────────────────────────── - pre-release: - name: Build RC Package - runs-on: ubuntu-latest - needs: [branch-policy, validate] - - steps: - - name: Trigger RC pre-release - env: - GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.head_ref }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" - echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY - echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY - - # ── Issue Reporter ────────────────────────────────────────────────────── - report-issues: - name: Report Issues - runs-on: ubuntu-latest - needs: [branch-policy, validate] - if: >- - always() && - needs.validate.result == 'failure' - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issue for PR validation failure" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - ./automation/ci-issue-reporter.sh \ - --gate "PR Validation" \ - --workflow "PR Check" \ - --severity error \ - --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." +# 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: 09.23.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: Check for merge conflict markers + run: | + CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true) + if [ -n "$CONFLICTS" ]; then + echo "::error::Merge conflict markers found in source files" + echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "No conflict markers found" + + - 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: Joomla JEXEC guard check + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + while IFS= read -r -d '' file; do + # Skip vendor, node_modules, and index.html stub files + case "$file" in ./vendor/*|./node_modules/*) continue ;; esac + # Check first 10 lines for JEXEC or JPATH guard + if ! head -20 "$file" | grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]"; then + echo "::error file=${file}::Missing JEXEC guard: ${file}" + ERRORS=$((ERRORS + 1)) + fi + done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0) + if [ "$ERRORS" -gt 0 ]; then + echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard" + echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY + echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "JEXEC guard: OK" + + - name: Joomla directory listing protection + if: steps.platform.outputs.platform == 'joomla' + run: | + MISSING=0 + SOURCE_DIR="src" + [ ! -d "$SOURCE_DIR" ] && exit 0 + while IFS= read -r dir; do + if [ ! -f "${dir}/index.html" ]; then + echo "::warning::Missing index.html in ${dir} (directory listing protection)" + MISSING=$((MISSING + 1)) + fi + done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*") + if [ "$MISSING" -gt 0 ]; then + echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY + echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY + fi + echo "Directory protection: ${MISSING} missing (advisory)" + + - name: Joomla script file and asset checks + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '/dev/null | head -1) + [ -z "$MANIFEST" ] && exit 0 + MANIFEST_DIR=$(dirname "$MANIFEST") + + # Check scriptfile exists if declared + SCRIPTFILE=$(sed -n 's/.*\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null) + if [ -n "$SCRIPTFILE" ]; then + if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then + echo "::error::Manifest declares ${SCRIPTFILE} but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}" + ERRORS=$((ERRORS + 1)) + else + echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)" + fi + fi + + # Require joomla.asset.json and validate it + ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$ASSET_JSON" ]; then + echo "::error::joomla.asset.json not found — Joomla asset system is required" + ERRORS=$((ERRORS + 1)) + else + if command -v php &> /dev/null; then + php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || { + echo "::error::joomla.asset.json is not valid JSON" + ERRORS=$((ERRORS + 1)) + } + fi + echo "joomla.asset.json: valid" + fi + + # Validate all XML files in src/ are well-formed + XML_ERRORS=0 + if command -v php &> /dev/null; then + while IFS= read -r -d '' xmlfile; do + if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then + XML_ERRORS=$((XML_ERRORS + 1)) + fi + done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0) + fi + if [ "$XML_ERRORS" -gt 0 ]; then + echo "::error::${XML_ERRORS} XML file(s) are malformed" + ERRORS=$((ERRORS + 1)) + else + echo "XML well-formedness: OK" + fi + + [ "$ERRORS" -gt 0 ] && exit 1 + echo "Joomla asset checks: OK" + + - 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 + # Block legacy raw/branch update server URLs on MokoGitea + RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true) + if [ -n "$RAW_URLS" ]; then + echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the Gitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)" + echo "$RAW_URLS" + exit 1 + fi + 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: Validate Joomla language files + if: steps.platform.outputs.platform == 'joomla' + run: | + ERRORS=0 + WARNINGS=0 + + # Require both en-GB and en-US language directories + LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1) + if [ -z "$LANG_ROOT" ]; then + echo "No language/ directory found — skipping" + exit 0 + fi + + if [ ! -d "$LANG_ROOT/en-GB" ]; then + echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)" + ERRORS=$((ERRORS + 1)) + fi + if [ ! -d "$LANG_ROOT/en-US" ]; then + echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)" + ERRORS=$((ERRORS + 1)) + fi + + # Check that en-GB and en-US have matching .ini files + if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then + for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do + [ ! -f "$GB_INI" ] && continue + US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")" + if [ ! -f "$US_INI" ]; then + echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US" + ERRORS=$((ERRORS + 1)) + fi + done + for US_INI in "$LANG_ROOT/en-US"/*.ini; do + [ ! -f "$US_INI" ] && continue + GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")" + if [ ! -f "$GB_INI" ]; then + echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB" + ERRORS=$((ERRORS + 1)) + fi + done + fi + + # Find all .ini language files + INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null) + if [ -z "$INI_FILES" ]; then + echo "No .ini language files found" + [ "$ERRORS" -gt 0 ] && exit 1 + exit 0 + fi + + echo "Found $(echo "$INI_FILES" | wc -l) language file(s)" + + for FILE in $INI_FILES; do + FNAME=$(basename "$FILE") + LINENUM=0 + SEEN_KEYS="" + + while IFS= read -r line || [ -n "$line" ]; do + LINENUM=$((LINENUM + 1)) + + # Skip empty lines and comments + [ -z "$line" ] && continue + echo "$line" | grep -qE '^\s*;' && continue + echo "$line" | grep -qE '^\s*$' && continue + + # Must match KEY="VALUE" format + if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then + echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}" + ERRORS=$((ERRORS + 1)) + continue + fi + + # Extract key and check for duplicates + KEY=$(echo "$line" | sed 's/=.*//') + if echo "$SEEN_KEYS" | grep -qx "$KEY"; then + echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}" + ERRORS=$((ERRORS + 1)) + fi + SEEN_KEYS="${SEEN_KEYS} + ${KEY}" + done < "$FILE" + + echo " ${FILE}: checked ${LINENUM} lines" + done + + # Cross-check en-GB vs en-US key consistency + GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1) + US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1) + + if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then + for GB_FILE in "$GB_DIR"/*.ini; do + [ ! -f "$GB_FILE" ] && continue + FNAME=$(basename "$GB_FILE") + US_FILE="$US_DIR/$FNAME" + [ ! -f "$US_FILE" ] && continue + + GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort) + US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort) + + # Keys in en-GB but not en-US + MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_US" ]; then + echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:" + echo "$MISSING_US" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + + # Keys in en-US but not en-GB + MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS")) + if [ -n "$MISSING_GB" ]; then + echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:" + echo "$MISSING_GB" | while read -r k; do echo " - $k"; done + WARNINGS=$((WARNINGS + 1)) + fi + done + fi + + { + echo "### Language File Validation" + echo "| Metric | Count |" + echo "|---|---|" + echo "| Files checked | $(echo "$INI_FILES" | wc -l) |" + echo "| Errors | ${ERRORS} |" + echo "| Warnings | ${WARNINGS} |" + } >> $GITHUB_STEP_SUMMARY + + if [ "$ERRORS" -gt 0 ]; then + echo "::error::Language validation failed with ${ERRORS} error(s)" + exit 1 + fi + echo "Language files: OK (${WARNINGS} warning(s))" + + - 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; } + + # ── Pre-Release RC Build ───────────────────────────────────────────────── + pre-release: + name: Build RC Package + runs-on: ubuntu-latest + needs: [branch-policy, validate] + + steps: + - name: Trigger RC pre-release + env: + GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.head_ref }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}" + echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY + echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY + + # ── Issue Reporter ────────────────────────────────────────────────────── + report-issues: + name: Report Issues + runs-on: ubuntu-latest + needs: [branch-policy, validate] + if: >- + always() && + needs.validate.result == 'failure' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issue for PR validation failure" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + ./automation/ci-issue-reporter.sh \ + --gate "PR Validation" \ + --workflow "PR Check" \ + --severity error \ + --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed." diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml new file mode 100644 index 0000000..86908c2 --- /dev/null +++ b/.mokogitea/workflows/pre-release.yml @@ -0,0 +1,243 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: Gitea.Workflow +# INGROUP: moko-platform.Release +# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform +# PATH: /templates/workflows/universal/pre-release.yml.template +# VERSION: 05.01.00 +# BRIEF: Manual pre-release -- builds dev/alpha/beta/rc packages from any branch + +name: "Universal: Pre-Release" + +on: + pull_request: + types: [closed] + branches: + - dev + pull_request_target: + types: [synchronize, opened, reopened] + branches: + - main + workflow_dispatch: + inputs: + stability: + description: 'Pre-release channel' + required: true + type: choice + options: + - development + - alpha + - beta + - release-candidate + +permissions: + contents: write + +env: + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + GITEA_ORG: ${{ vars.GITEA_ORG || github.repository_owner }} + GITEA_REPO: ${{ vars.GITEA_REPO || github.event.repository.name }} + +jobs: + build: + name: "Build Pre-Release (${{ inputs.stability || 'development' }})" + runs-on: release + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'dev') || + (github.event_name == 'pull_request_target' && github.event.pull_request.base.ref == 'main') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.MOKOGITEA_TOKEN }} + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || '' }} + + - name: Setup moko-platform tools + env: + MOKO_CLONE_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + MOKO_CLONE_HOST: git.mokoconsulting.tech/MokoConsulting + run: | + # Use pre-installed /opt/moko-platform if available (updated by cron every 6h) + if [ -f /opt/moko-platform/cli/version_bump.php ] && [ -f /opt/moko-platform/cli/manifest_element.php ] && [ -f /opt/moko-platform/vendor/autoload.php ]; then + echo Using pre-installed /opt/moko-platform + echo MOKO_CLI=/opt/moko-platform/cli >> $GITHUB_ENV + else + echo Falling back to fresh clone + if ! command -v composer > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer > /dev/null 2>&1 + fi + rm -rf /tmp/moko-platform-api + CLONE_URL=https://x-access-token:${MOKO_CLONE_TOKEN}@${MOKO_CLONE_HOST}/moko-platform.git + git clone --depth 1 --branch main --quiet $CLONE_URL /tmp/moko-platform-api + cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet + echo MOKO_CLI=/tmp/moko-platform-api/cli >> $GITHUB_ENV + fi + + - name: Detect platform + id: platform + run: | + php ${MOKO_CLI}/manifest_read.php --path . --github-output + + - name: Resolve metadata and bump version + id: meta + run: | + # Auto-detect stability: RC for PRs targeting main, else use input or default to development + if [ "${{ github.event_name }}" = "pull_request_target" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then + STABILITY="release-candidate" + else + STABILITY="${{ inputs.stability || 'development' }}" + fi + + case "$STABILITY" in + development) SUFFIX="-dev"; TAG="development" ;; + alpha) SUFFIX="-alpha"; TAG="alpha" ;; + beta) SUFFIX="-beta"; TAG="beta" ;; + release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;; + esac + + # Bump version via CLI: patch for dev/alpha/beta, minor for RC + case "$STABILITY" in + release-candidate) BUMP="minor" ;; + *) BUMP="patch" ;; + esac + + php ${MOKO_CLI}/version_bump.php --path . $([ "$BUMP" = "minor" ] && echo "--minor") 2>/dev/null || true + + # Set stability suffix and verify consistency + VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01") + VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//') + + php ${MOKO_CLI}/version_set_platform.php \ + --path . --version "$VERSION" --branch "${{ github.ref_name }}" --stability "$STABILITY" 2>/dev/null || true + php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true + + # Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml + php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true + + # Append suffix for output + if [ -n "$SUFFIX" ]; then + VERSION="${VERSION}${SUFFIX}" + fi + + # Commit version bump + git config --local user.email "gitea-actions[bot]@mokoconsulting.tech" + git config --local user.name "gitea-actions[bot]" + git remote set-url origin "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git" + git add -A + git diff --cached --quiet || { + git commit -m "chore(version): pre-release bump to ${VERSION} [skip ci]" + git push origin HEAD 2>&1 + } + + # Auto-detect element via manifest_element.php + php ${MOKO_CLI}/manifest_element.php \ + --path . --version "$VERSION" --stability "$STABILITY" \ + --repo "${GITEA_REPO}" --github-output + + # Read back element outputs + EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2) + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GITEA_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -') + [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip" + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT" + echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT" + echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT" + + echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION}${SUFFIX} ===" + + - name: Create release + id: release + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_create.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --branch dev --prerelease + + - name: Update release notes from CHANGELOG.md + run: | + TAG="${{ steps.meta.outputs.tag }}" + VERSION="${{ steps.meta.outputs.version }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + + # Extract [Unreleased] section from changelog (everything between [Unreleased] and next ## heading) + if [ -f "CHANGELOG.md" ]; then + NOTES=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + [ -z "$NOTES" ] && NOTES="Release ${VERSION}" + else + NOTES="Release ${VERSION}" + fi + + # Update release body via API + RELEASE_ID=$(curl -sf -H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \ + "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) + + if [ -n "$RELEASE_ID" ]; then + python3 -c " + import json, urllib.request + body = open('/dev/stdin').read() + payload = json.dumps({'body': body}).encode() + req = urllib.request.Request( + '${API_BASE}/releases/${RELEASE_ID}', + data=payload, method='PATCH', + headers={ + 'Authorization': 'token ${{ secrets.MOKOGITEA_TOKEN }}', + 'Content-Type': 'application/json' + }) + urllib.request.urlopen(req) + " <<< "$NOTES" + echo "Release notes updated from CHANGELOG.md" + fi + + - name: Build package and upload + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + php ${MOKO_CLI}/release_package.php \ + --path . --version "$VERSION" --tag "$TAG" \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" --api-base "$API_BASE" \ + --repo "${GITEA_REPO}" --output /tmp || true + + # updates.xml is generated dynamically by MokoGitea license server + # No need to build, commit, or sync updates.xml from workflows + + - name: "Delete lesser pre-release channels (cascade)" + continue-on-error: true + run: | + API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" + TOKEN="${{ secrets.MOKOGITEA_TOKEN }}" + + php ${MOKO_CLI}/release_cascade.php \ + --stability "${{ steps.meta.outputs.stability }}" \ + --token "${TOKEN}" \ + --api-base "${API_BASE}" + + - name: Summary + if: always() + run: | + VERSION="${{ steps.meta.outputs.version }}" + STABILITY="${{ steps.meta.outputs.stability }}" + ZIP_NAME="${{ steps.meta.outputs.zip_name }}" + SHA256="${{ steps.package.outputs.sha256_zip }}" + echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY + echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY + echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY diff --git a/.mokogitea/workflows/repo-health.yml b/.mokogitea/workflows/repo-health.yml index 8d57aaf..d0538d5 100644 --- a/.mokogitea/workflows/repo-health.yml +++ b/.mokogitea/workflows/repo-health.yml @@ -1,711 +1,711 @@ -# ============================================================================ -# 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: 09.23.00 -# BRIEF: Enforces repository guardrails by validating 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, scripts, or repo' - required: true - default: all - type: choice - options: - - all - - scripts - - repo - pull_request: - push: - -permissions: - contents: read - -env: - # 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 - - 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|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${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|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${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 policy | N/A | Releases handled by MokoGitea |' - 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 - - # ═══════════════════════════════════════════════════════════════════════ - # Issue Reporter — file issues for failed gates - # ═══════════════════════════════════════════════════════════════════════ - report-issues: - name: "Report Issues" - runs-on: ubuntu-latest - needs: [access_check, scripts_governance, repo_health] - if: >- - always() && - (needs.scripts_governance.result == 'failure' || - needs.repo_health.result == 'failure') - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - sparse-checkout: automation/ci-issue-reporter.sh - sparse-checkout-cone-mode: false - - - name: "File issues for failed gates" - env: - GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} - GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} - run: | - chmod +x automation/ci-issue-reporter.sh - REPORTER="./automation/ci-issue-reporter.sh" - WF="Repo Health" - - report_gate() { - local gate="$1" result="$2" details="$3" - if [ "$result" = "failure" ]; then - "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error - fi - } - - report_gate "Scripts Governance" \ - "${{ needs.scripts_governance.result }}" \ - "Scripts directory policy violations detected. Review required and allowed directories." - - report_gate "Repository Health" \ - "${{ needs.repo_health.result }}" \ - "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." +# ============================================================================ +# 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: 09.23.00 +# BRIEF: Enforces repository guardrails by validating 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, scripts, or repo' + required: true + default: all + type: choice + options: + - all + - scripts + - repo + pull_request: + push: + +permissions: + contents: read + +env: + # 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 + + 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|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${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|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${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 policy | N/A | Releases handled by MokoGitea |' + 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 + + # ═══════════════════════════════════════════════════════════════════════ + # Issue Reporter — file issues for failed gates + # ═══════════════════════════════════════════════════════════════════════ + report-issues: + name: "Report Issues" + runs-on: ubuntu-latest + needs: [access_check, scripts_governance, repo_health] + if: >- + always() && + (needs.scripts_governance.result == 'failure' || + needs.repo_health.result == 'failure') + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: automation/ci-issue-reporter.sh + sparse-checkout-cone-mode: false + + - name: "File issues for failed gates" + env: + GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }} + GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }} + run: | + chmod +x automation/ci-issue-reporter.sh + REPORTER="./automation/ci-issue-reporter.sh" + WF="Repo Health" + + report_gate() { + local gate="$1" result="$2" details="$3" + if [ "$result" = "failure" ]; then + "$REPORTER" --gate "$gate" --details "$details" --workflow "$WF" --severity error + fi + } + + report_gate "Scripts Governance" \ + "${{ needs.scripts_governance.result }}" \ + "Scripts directory policy violations detected. Review required and allowed directories." + + report_gate "Repository Health" \ + "${{ needs.repo_health.result }}" \ + "Repository health checks failed — missing required artifacts, disallowed files, or content warnings. Check the CI run summary." diff --git a/.mokogitea/workflows/security-audit.yml b/.mokogitea/workflows/security-audit.yml index 714d407..1bd9470 100644 --- a/.mokogitea/workflows/security-audit.yml +++ b/.mokogitea/workflows/security-audit.yml @@ -7,7 +7,7 @@ # INGROUP: moko-platform.Security # REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform # PATH: /.gitea/workflows/security-audit.yml -# VERSION: 01.00.00 +# VERSION: 09.23.00 # BRIEF: Dependency vulnerability scanning for composer and npm packages name: "Universal: Security Audit" diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db115d..5fb1927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,62 +2,94 @@ ## [Unreleased] -## 01.00.00 — 2026-06-02 +### Added +- Joomla-styled standalone installer (MokoRestore) with 7-step wizard, admin password reset, and client provisioning +- Web cron trigger for shared hosting without crontab — URL-based backup with secret word, IP whitelist +- Placeholder support for backup directories and archive filenames ([host], [date], [site_name], [profile_name], etc.) +- FolderPicker JS placeholder resolution — resolves [site_name]/[host] when browsing, reverse-replaces on selection for portable profiles +- Archive Name Format field on backup profiles with customizable filename templates +- Interactive directory tree browser for exclude filters (replaces plain text input) +- Backup log viewer modal in backup records list and inline in detail view +- Clickable dashboard status tiles linking to backup records, detail views, and scheduled tasks +- Table exclusion now supports separate Data and Structure checkboxes (backward compatible) +- Tar.gz archive format support +- User group notifications for backup events +- Folder picker field with live server directory browsing +- Default directory dashboard warning when backups are stored inside web root +- Backup log files written alongside archives (.log) +- Backup detail view with checksum, file path, DB size, and embedded log +- Browser beforeunload warning during backup progress + +### Changed +- Renamed all extension elements from mokobackup to mokojoombackup (pkg, com, all plugins, DB tables, namespaces, language keys) +- Renamed source directory from src/ to source/ per MokoStandards convention +- Dashboard health check shows actual resolved backup directory path from profiles +- Update site post-install notice links to filtered list view (avoids Joomla core bug) +- License warning suppressed when download key is already configured +- Download key preserved across package updates via preflight/postflight backup + +### Fixed +- Download ERR_INVALID_RESPONSE — flush output buffers before sending file headers +- Backup directory path resolution for absolute paths outside web root +- Schema migrations consolidated to version within extension range +- PSR-4 class file naming (MokoBackup*.php → MokoJoomBackup*.php) +- Nested package directories from rename flattened +- INSERT IGNORE for default profile prevents duplicate key on update +- ActionlogsHelper::getIp() replaced — method does not exist in Joomla 5 +- Console plugin namespace and quickicon translation keys +- CLI exit codes and SQL schema defaults +- Component Options page (added config.xml) +- Placeholder-aware directory checks in FolderPicker and dashboard health + +## 01.01 — 2026-06-04 + +### Added +- Admin dashboard view as default landing page with status cards, quick actions, and system health checklist (#28) +- Console plugin (plg_console_mokojoombackup) — CLI commands: run, list, profiles, restore, cleanup (#29) +- Content plugin (plg_content_mokojoombackup) — auto-backup before extension install/update (#30) +- Actionlog plugin (plg_actionlog_mokojoombackup) — logs backup and profile actions to User Action Logs (#31) +- BackupEngine dispatches onMokoJoomBackupAfterRun event for plugin listeners +- Update site notice on dashboard and post-install + +### Changed +- Renamed Kickstart to MokoRestore throughout + +### Fixed +- SQL update migration and error handling +- Removed orphaned scriptfile from component manifest +- Consolidated admin files into single files block + +## 01.00 — 2026-06-02 ### Added - Initial package structure with component, system plugin, task plugin, and webservices plugin -- Joomla Scheduled Tasks integration (plg_task_mokobackup) — create multiple tasks, each running a different backup profile on its own schedule +- Joomla Scheduled Tasks integration (plg_task_mokojoombackup) — create multiple tasks, each running a different backup profile on its own schedule - Individual form fields for all profile settings (no raw JSON) - FTP/FTPS uploader with recursive directory creation, passive mode, SSL, and size verification -- Google Drive uploader using OAuth2 refresh tokens and resumable upload API (5 MB chunks) +- Google Drive uploader using OAuth2 refresh tokens and resumable upload API +- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16) - RemoteUploaderInterface for pluggable storage backends -- Remote upload integrated into BackupEngine as Step 3 after archive creation -- Option to delete local copy after successful remote upload (per-profile setting) +- Remote upload integrated into BackupEngine with option to delete local copy after upload - Restore engine with file restoration and database import -- Standalone Kickstart restore script (restore.php) — self-contained site restoration without Joomla, like Akeeba Kickstart -- "Include Restore Script" toggle per profile — wraps backup with restore.php + site-backup.zip -- FileRestorer class with protected file handling (preserves configuration.php, .htaccess) +- MokoRestore standalone restore script — self-contained site restoration without Joomla +- "Include Restore Script" toggle per profile +- FileRestorer with protected file handling (preserves configuration.php, .htaccess) - DatabaseImporter with streaming line-by-line SQL execution and error tolerance - Admin dashboard quickicon widget — backup status at a glance with warnings (#18) - Differential backups — only back up files changed since last full backup (#19) -- DifferentialScanner: builds file manifests (path/size/mtime) and compares against base -- File manifest stored in backup record for future differential comparisons -- Automatic full-backup fallback when no base manifest exists +- DifferentialScanner with file manifests stored in backup records - JPA archive format import for Akeeba Backup migration (#20) -- JpaUnarchiver: parses Akeeba JPA binary format (headers, gzip, permissions) -- RestoreEngine auto-detects JPA vs ZIP format - AES-256 archive encryption with per-profile password (#17) -- Encrypted archive support in RestoreEngine (password parameter) -- Encrypted archive support in Kickstart restore.php (password field in UI) -- SHA-256 checksum computed and stored after archive creation (#15) -- "Verify Integrity" toolbar button re-computes hash and compares against stored checksum -- S3-compatible remote storage: AWS S3, Wasabi, Backblaze B2, MinIO (#16) -- S3 uploader with AWS Signature V4, single PUT for files <= 100 MB, multipart for larger -- S3 fields in profile form with showon conditional visibility -- Akeeba importer now maps S3 credentials from Akeeba profiles +- SHA-256 checksum verification for backup integrity (#15) - Email notifications on backup success/failure via Joomla mailer (#14) -- Per-profile notification settings: recipient emails, notify on success/failure -- Failure emails include last 30 lines of backup log for debugging -- mcp_mokobackup MCP server updated with MokoBackupClient for dual-backend support (#21) -- Akeeba Backup Pro importer — imports profiles, filters, remote storage settings, and backup history +- Akeeba Backup Pro importer — profiles, filters, remote storage, and backup history - Auto-disables Akeeba plugins and scheduled tasks after successful import -- "Import from Akeeba" toolbar button in Profiles view (only shown when Akeeba tables detected) -- Supports both INI-format and JSON-format Akeeba configuration parsing -- Maps Akeeba filter format (per-root, nested) to newline-separated exclusion fields -- Profile selector dropdown in Backup Records view for choosing which profile to run - AJAX step-based backup engine for shared hosting (overcomes max_execution_time) -- SteppedBackupEngine: breaks backup into per-table DB dumps and file batches -- SteppedSession: persistent state between AJAX requests via temp JSON files - Progress bar modal in admin UI with real-time phase/percentage updates -- AjaxController for init/step endpoints with CSRF protection - Per-profile archive settings: format, compression level, split size, backup directory -- 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 engine with database dumper, file scanner, and 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 +- REST API compatible with MokoJoomBackup MCP server - System plugin for automatic backup cleanup with configurable retention diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7049fa1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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) -- Cleanup of expired backup archives (age + count limits) -- Namespace: `Joomla\Plugin\System\MokoBackup` - -### plg_task_mokobackup (Task Plugin) -- Integrates with Joomla's Scheduled Tasks (com_scheduler) -- Registers "Run Backup Profile" task type -- Each scheduled task selects a backup profile — create multiple tasks for different schedules -- Namespace: `Joomla\Plugin\Task\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/Makefile b/Makefile index 50e3eae..bafba81 100644 --- a/Makefile +++ b/Makefile @@ -3,43 +3,29 @@ # SPDX-License-Identifier: GPL-3.0-or-later # # MokoJoomBackup — Full-site backup and restore for Joomla +# +# Builds and releases are handled by CI workflows (pre-release.yml, +# auto-release.yml). This Makefile provides local validation helpers +# and workflow dispatch shortcuts. # ============================================================================== -# CONFIGURATION - Customize these for your extension +# CONFIGURATION # ============================================================================== -# Extension Configuration -EXTENSION_NAME := mokobackup +EXTENSION_NAME := mokojoombackup 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 +SRC_DIR := source -# 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 +# Gitea +GITEA_URL := https://git.mokoconsulting.tech +GITEA_ORG := MokoConsulting +GITEA_REPO := MokoJoomBackup # 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 @@ -58,146 +44,122 @@ COLOR_RED := \033[31m .PHONY: help help: ## Show this help message @echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)" - @echo "$(COLOR_BLUE)║ Joomla Extension Makefile ║$(COLOR_RESET)" + @echo "$(COLOR_BLUE)║ MokoJoomBackup Makefile ║$(COLOR_RESET)" @echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)" @echo "" - @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 +# -- Validation ---------------------------------------------------------------- .PHONY: lint -lint: ## Run PHP linter (syntax check) +lint: ## Run PHP syntax check on all source files @echo "$(COLOR_BLUE)Running PHP linter...$(COLOR_RESET)" - @find . -name "*.php" ! -path "./vendor/*" ! -path "./node_modules/*" ! -path "./$(BUILD_DIR)/*" \ - -exec $(PHP) -l {} \; | grep -v "No syntax errors" || true + @ERROR=0; \ + find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -v "No syntax errors" || true; \ + if find $(SRC_DIR) -name "*.php" -exec $(PHP) -l {} \; 2>&1 | grep -q "Parse error"; then \ + echo "$(COLOR_RED)✗ Syntax errors found$(COLOR_RESET)"; exit 1; \ + fi @echo "$(COLOR_GREEN)✓ PHP linting complete$(COLOR_RESET)" .PHONY: phpcs phpcs: ## Run PHP CodeSniffer (Joomla standards) @echo "$(COLOR_BLUE)Running PHP CodeSniffer...$(COLOR_RESET)" @if [ -f "$(PHPCS)" ]; then \ - $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php --ignore=vendor,node_modules,$(BUILD_DIR) .; \ + $(PHPCS) --standard=$(PHPCS_STANDARD) --extensions=php $(SRC_DIR); \ else \ - echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: make install-deps$(COLOR_RESET)"; \ + echo "$(COLOR_YELLOW)⚠ PHP CodeSniffer not installed. Run: composer install$(COLOR_RESET)"; \ fi .PHONY: validate -validate: lint phpcs ## Run all validation checks - @echo "$(COLOR_GREEN)✓ All validation checks passed$(COLOR_RESET)" +validate: lint ## Run all local validation checks + @echo "$(COLOR_GREEN)✓ Validation 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)" +.PHONY: validate-xml +validate-xml: ## Validate all XML manifests are well-formed + @echo "$(COLOR_BLUE)Validating XML manifests...$(COLOR_RESET)" + @ERROR=0; \ + for f in $$(find $(SRC_DIR) -name "*.xml"); do \ + $(PHP) -r "new SimpleXMLElement(file_get_contents('$$f'));" 2>/dev/null \ + || { echo "$(COLOR_RED)✗ Invalid XML: $$f$(COLOR_RESET)"; ERROR=1; }; \ + done; \ + [ $$ERROR -eq 0 ] && echo "$(COLOR_GREEN)✓ All XML manifests valid$(COLOR_RESET)" || exit 1 + +# -- Dependencies -------------------------------------------------------------- + +.PHONY: install-deps +install-deps: ## Install PHP dependencies via Composer + @echo "$(COLOR_BLUE)Installing dependencies...$(COLOR_RESET)" + @if [ -f "composer.json" ]; then \ + $(COMPOSER) install; \ + echo "$(COLOR_GREEN)✓ Composer dependencies installed$(COLOR_RESET)"; \ + fi + +.PHONY: security-check +security-check: ## Run security audit on dependencies + @echo "$(COLOR_BLUE)Running security checks...$(COLOR_RESET)" + @if [ -f "composer.json" ]; then \ + $(COMPOSER) audit || echo "$(COLOR_YELLOW)⚠ Vulnerabilities found$(COLOR_RESET)"; \ + fi + +# -- Minify -------------------------------------------------------------------- MOKO_PLATFORM ?= $(or $(wildcard ../moko-platform),$(wildcard $(HOME)/moko-platform),$(wildcard /opt/moko-platform)) MINIFY_SCRIPT := $(MOKO_PLATFORM)/build/minify.js .PHONY: minify minify: ## Minify CSS/JS assets - @echo "Minifying assets..." + @echo "$(COLOR_BLUE)Minifying assets...$(COLOR_RESET)" @if [ -f "$(MINIFY_SCRIPT)" ]; then \ node "$(MINIFY_SCRIPT)" $(SRC_DIR); \ elif [ -f "scripts/minify.js" ]; then \ node scripts/minify.js; \ else \ - echo "No minify script found"; \ + echo "$(COLOR_YELLOW)⚠ No minify script found$(COLOR_RESET)"; \ 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)" +# -- Release (CI workflow dispatch) -------------------------------------------- .PHONY: release -release: validate build ## Create a release (validate + build) - @echo "$(COLOR_GREEN)✓ Release package ready$(COLOR_RESET)" +release: validate validate-xml ## Trigger pre-release build via CI workflow + @echo "$(COLOR_BLUE)Triggering pre-release workflow...$(COLOR_RESET)" + @if ! command -v curl >/dev/null 2>&1; then \ + echo "$(COLOR_RED)✗ curl required$(COLOR_RESET)"; exit 1; \ + fi + @if [ -z "$$MOKOGITEA_TOKEN" ]; then \ + echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + curl -sf -X POST \ + -H "Authorization: token $$MOKOGITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \ + -d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"development\"}}" \ + && echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (development channel)$(COLOR_RESET)" \ + || { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; } + +.PHONY: release-rc +release-rc: validate validate-xml ## Trigger release-candidate build via CI workflow + @echo "$(COLOR_BLUE)Triggering RC pre-release workflow...$(COLOR_RESET)" + @if [ -z "$$MOKOGITEA_TOKEN" ]; then \ + echo "$(COLOR_RED)✗ MOKOGITEA_TOKEN not set$(COLOR_RESET)"; exit 1; \ + fi + @BRANCH=$$(git rev-parse --abbrev-ref HEAD); \ + curl -sf -X POST \ + -H "Authorization: token $$MOKOGITEA_TOKEN" \ + -H "Content-Type: application/json" \ + "$(GITEA_URL)/api/v1/repos/$(GITEA_ORG)/$(GITEA_REPO)/actions/workflows/pre-release.yml/dispatches" \ + -d "{\"ref\":\"$$BRANCH\",\"inputs\":{\"stability\":\"release-candidate\"}}" \ + && echo "$(COLOR_GREEN)✓ Pre-release dispatched on $$BRANCH (release-candidate channel)$(COLOR_RESET)" \ + || { echo "$(COLOR_RED)✗ Dispatch failed$(COLOR_RESET)"; exit 1; } + +# -- Info ---------------------------------------------------------------------- .PHONY: version -version: ## Display version 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)" +version: ## Display version from package manifest + @VERSION=$$(grep '' $(SRC_DIR)/pkg_mokojoombackup.xml | sed 's/.*\(.*\)<\/version>.*/\1/'); \ + echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))" # Default target .DEFAULT_GOAL := help diff --git a/README.md b/README.md index ec51852..b672e0e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoJoomBackup - + Full-site backup and restore for Joomla — database, files, and configuration. diff --git a/src/index.html b/source/index.html similarity index 100% rename from src/index.html rename to source/index.html diff --git a/src/language/en-GB/index.html b/source/language/en-GB/index.html similarity index 100% rename from src/language/en-GB/index.html rename to source/language/en-GB/index.html diff --git a/source/language/en-GB/pkg_mokojoombackup.sys.ini b/source/language/en-GB/pkg_mokojoombackup.sys.ini new file mode 100644 index 0000000..bf27f12 --- /dev/null +++ b/source/language/en-GB/pkg_mokojoombackup.sys.ini @@ -0,0 +1,10 @@ +; 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_MOKOJOOMBACKUP="Package - MokoJoomBackup" +PKG_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." +PKG_MOKOJOOMBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." +PKG_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." diff --git a/src/language/en-US/index.html b/source/language/en-US/index.html similarity index 100% rename from src/language/en-US/index.html rename to source/language/en-US/index.html diff --git a/source/language/en-US/pkg_mokojoombackup.sys.ini b/source/language/en-US/pkg_mokojoombackup.sys.ini new file mode 100644 index 0000000..07d63b6 --- /dev/null +++ b/source/language/en-US/pkg_mokojoombackup.sys.ini @@ -0,0 +1,10 @@ +; 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_MOKOJOOMBACKUP="Package - MokoJoomBackup" +PKG_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration. Includes admin component, system plugin, and REST API." +PKG_MOKOJOOMBACKUP_PHP_VERSION_ERROR="MokoJoomBackup requires PHP %s or later." +PKG_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." diff --git a/src/language/index.html b/source/language/index.html similarity index 100% rename from src/language/index.html rename to source/language/index.html diff --git a/src/packages/com_mokobackup/api/index.html b/source/packages/com_mokojoombackup/api/index.html similarity index 100% rename from src/packages/com_mokobackup/api/index.html rename to source/packages/com_mokojoombackup/api/index.html diff --git a/src/packages/com_mokobackup/api/src/Controller/BackupsController.php b/source/packages/com_mokojoombackup/api/src/Controller/BackupsController.php similarity index 84% rename from src/packages/com_mokobackup/api/src/Controller/BackupsController.php rename to source/packages/com_mokojoombackup/api/src/Controller/BackupsController.php index ab7c746..1f5b829 100644 --- a/src/packages/com_mokobackup/api/src/Controller/BackupsController.php +++ b/source/packages/com_mokojoombackup/api/src/Controller/BackupsController.php @@ -2,18 +2,18 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Api\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\ApiController; -use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\BackupEngine; class BackupsController extends ApiController { @@ -21,7 +21,7 @@ class BackupsController extends ApiController protected $default_view = 'backups'; /** - * Start a new backup (POST /api/index.php/v1/mokobackup/backup) + * Start a new backup (POST /api/index.php/v1/mokojoombackup/backup) */ public function backup(): static { @@ -47,7 +47,7 @@ class BackupsController extends ApiController } /** - * Download a backup archive (GET /api/index.php/v1/mokobackup/backup/:id/download) + * Download a backup archive (GET /api/index.php/v1/mokojoombackup/backup/:id/download) */ public function download(): static { @@ -74,7 +74,7 @@ class BackupsController extends ApiController } /** - * List backup profiles (GET /api/index.php/v1/mokobackup/profiles) + * List backup profiles (GET /api/index.php/v1/mokojoombackup/profiles) */ public function profiles(): static { diff --git a/src/packages/com_mokobackup/api/src/Controller/index.html b/source/packages/com_mokojoombackup/api/src/Controller/index.html similarity index 100% rename from src/packages/com_mokobackup/api/src/Controller/index.html rename to source/packages/com_mokojoombackup/api/src/Controller/index.html diff --git a/src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php b/source/packages/com_mokojoombackup/api/src/View/Backups/JsonapiView.php similarity index 90% rename from src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php rename to source/packages/com_mokojoombackup/api/src/View/Backups/JsonapiView.php index d9e8a8a..f39925f 100644 --- a/src/packages/com_mokobackup/api/src/View/Backups/JsonapiView.php +++ b/source/packages/com_mokojoombackup/api/src/View/Backups/JsonapiView.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Api\View\Backups; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/api/src/View/Backups/index.html b/source/packages/com_mokojoombackup/api/src/View/Backups/index.html similarity index 100% rename from src/packages/com_mokobackup/api/src/View/Backups/index.html rename to source/packages/com_mokojoombackup/api/src/View/Backups/index.html diff --git a/src/packages/com_mokobackup/api/src/View/index.html b/source/packages/com_mokojoombackup/api/src/View/index.html similarity index 100% rename from src/packages/com_mokobackup/api/src/View/index.html rename to source/packages/com_mokojoombackup/api/src/View/index.html diff --git a/src/packages/com_mokobackup/api/src/index.html b/source/packages/com_mokojoombackup/api/src/index.html similarity index 100% rename from src/packages/com_mokobackup/api/src/index.html rename to source/packages/com_mokojoombackup/api/src/index.html diff --git a/src/packages/com_mokobackup/cli/index.html b/source/packages/com_mokojoombackup/cli/index.html similarity index 100% rename from src/packages/com_mokobackup/cli/index.html rename to source/packages/com_mokojoombackup/cli/index.html diff --git a/src/packages/com_mokobackup/cli/mokobackup.php b/source/packages/com_mokojoombackup/cli/mokojoombackup.php similarity index 89% rename from src/packages/com_mokobackup/cli/mokobackup.php rename to source/packages/com_mokojoombackup/cli/mokojoombackup.php index 47f030e..9135706 100644 --- a/src/packages/com_mokobackup/cli/mokobackup.php +++ b/source/packages/com_mokojoombackup/cli/mokojoombackup.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -10,7 +10,7 @@ * CLI backup script for cron/scheduled use. * * Usage: - * php cli/mokobackup.php --profile=1 --description="Scheduled backup" + * php cli/mokojoombackup.php --profile=1 --description="Scheduled backup" * * Must be run from the Joomla root directory. */ @@ -30,7 +30,7 @@ if (!defined('JPATH_BASE')) { require_once JPATH_BASE . '/includes/framework.php'; use Joomla\CMS\Factory; -use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\BackupEngine; // Parse CLI arguments $profileId = 1; diff --git a/source/packages/com_mokojoombackup/config.xml b/source/packages/com_mokojoombackup/config.xml new file mode 100644 index 0000000..98c5a0d --- /dev/null +++ b/source/packages/com_mokojoombackup/config.xml @@ -0,0 +1,140 @@ + + + +
+ + + + + + + + +
+ +
+ + + + + + +
+ +
+ + +
+ +
+ + + + + + + + + +
+ +
+ +
+
diff --git a/source/packages/com_mokojoombackup/forms/backup.xml b/source/packages/com_mokojoombackup/forms/backup.xml new file mode 100644 index 0000000..207f50b --- /dev/null +++ b/source/packages/com_mokojoombackup/forms/backup.xml @@ -0,0 +1,15 @@ + +
+
+ + + + + + + + + + +
+
diff --git a/source/packages/com_mokojoombackup/forms/filter_backups.xml b/source/packages/com_mokojoombackup/forms/filter_backups.xml new file mode 100644 index 0000000..11af4cc --- /dev/null +++ b/source/packages/com_mokojoombackup/forms/filter_backups.xml @@ -0,0 +1,47 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/packages/com_mokobackup/forms/filter_profiles.xml b/source/packages/com_mokojoombackup/forms/filter_profiles.xml similarity index 81% rename from src/packages/com_mokobackup/forms/filter_profiles.xml rename to source/packages/com_mokojoombackup/forms/filter_profiles.xml index 0025a94..b3ec39a 100644 --- a/src/packages/com_mokobackup/forms/filter_profiles.xml +++ b/source/packages/com_mokojoombackup/forms/filter_profiles.xml @@ -4,7 +4,7 @@ - - + + diff --git a/src/packages/com_mokobackup/forms/index.html b/source/packages/com_mokojoombackup/forms/index.html similarity index 100% rename from src/packages/com_mokobackup/forms/index.html rename to source/packages/com_mokojoombackup/forms/index.html diff --git a/source/packages/com_mokojoombackup/forms/profile.xml b/source/packages/com_mokojoombackup/forms/profile.xml new file mode 100644 index 0000000..34701a8 --- /dev/null +++ b/source/packages/com_mokojoombackup/forms/profile.xml @@ -0,0 +1,373 @@ + +
+
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+ +
+ + + + +
+ +
+ + + + + + +
+
diff --git a/src/packages/com_mokobackup/index.html b/source/packages/com_mokojoombackup/index.html similarity index 100% rename from src/packages/com_mokobackup/index.html rename to source/packages/com_mokojoombackup/index.html diff --git a/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.ini b/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.ini new file mode 100644 index 0000000..5c8ea6c --- /dev/null +++ b/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.ini @@ -0,0 +1,274 @@ +; 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_MOKOJOOMBACKUP="MokoJoomBackup" +COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" + +; Submenu +COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles" + +; Dashboard view +COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard" +COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup" +COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet" +COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled" +COM_MOKOJOOMBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled" +COM_MOKOJOOMBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups" +COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE="Storage Used" +COM_MOKOJOOMBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)" +COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions" +COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks" +COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site" +COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health" + +; Backups view +COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" +COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records" +COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." +COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" +COM_MOKOJOOMBACKUP_DOWNLOAD="Download" + +; Backup detail view +COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail" +COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log" +COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum" +COM_MOKOJOOMBACKUP_FIELD_PATH="File Path" +COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size" +COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path" + +; Profiles view +COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" +COM_MOKOJOOMBACKUP_PROFILES_TABLE_CAPTION="Table of backup profiles" +COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile" +COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile" + +; Table headings +COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description" +COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile" +COM_MOKOJOOMBACKUP_HEADING_STATUS="Status" +COM_MOKOJOOMBACKUP_HEADING_TYPE="Type" +COM_MOKOJOOMBACKUP_HEADING_SIZE="Size" +COM_MOKOJOOMBACKUP_HEADING_DATE="Date" +COM_MOKOJOOMBACKUP_HEADING_ACTIONS="Actions" +COM_MOKOJOOMBACKUP_HEADING_TITLE="Title" +COM_MOKOJOOMBACKUP_HEADING_DATE_DESC="Date descending" +COM_MOKOJOOMBACKUP_HEADING_DATE_ASC="Date ascending" +COM_MOKOJOOMBACKUP_HEADING_SIZE_DESC="Size descending" +COM_MOKOJOOMBACKUP_HEADING_SIZE_ASC="Size ascending" +COM_MOKOJOOMBACKUP_HEADING_TITLE_ASC="Title ascending" +COM_MOKOJOOMBACKUP_HEADING_TITLE_DESC="Title descending" + +; General fields +COM_MOKOJOOMBACKUP_FIELD_TITLE="Title" +COM_MOKOJOOMBACKUP_FIELD_TITLE_DESC="Profile name" +COM_MOKOJOOMBACKUP_FIELD_DESCRIPTION="Description" +COM_MOKOJOOMBACKUP_FIELD_DESCRIPTION_DESC="Brief description of this profile" +COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE="Backup Type" +COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE_DESC="What to include in the backup" +COM_MOKOJOOMBACKUP_FIELD_STATUS="Status" +COM_MOKOJOOMBACKUP_FIELD_ORIGIN="Origin" +COM_MOKOJOOMBACKUP_FIELD_SIZE="Total Size" +COM_MOKOJOOMBACKUP_FIELD_START="Start Time" +COM_MOKOJOOMBACKUP_FIELD_END="End Time" +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE="Archive Name" +COM_MOKOJOOMBACKUP_FIELD_FILES_COUNT="Files Count" +COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count" + +; Archive settings +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format" +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file" +COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level" +COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower" +COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)" +COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)" +COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)" +COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)" +COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password" +COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups." +COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)" +COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting." +COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory" +COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root." +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format" +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]." +COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script" +COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed." + +; Exclusion filter fields +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually." +COM_MOKOJOOMBACKUP_FILTER_EXCLUDED="Excluded" +COM_MOKOJOOMBACKUP_FILTER_INCLUDED="Included" +COM_MOKOJOOMBACKUP_FILTER_ADD_MANUAL="Add Path" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_FILES="Exclude Files" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_FILES_DESC="One filename or pattern per line. Supports wildcards (e.g. *.bak, *.tmp)." +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES="Exclude Tables" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_DESC="One table name per line (use #__ prefix). These tables will be skipped during database dump." + +; Remote storage fields +COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE="Remote Storage" +COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE_DESC="Optionally upload backup archives to a remote location after creation" +COM_MOKOJOOMBACKUP_REMOTE_NONE="None (local only)" +COM_MOKOJOOMBACKUP_REMOTE_FTP="FTP / FTPS" +COM_MOKOJOOMBACKUP_REMOTE_GDRIVE="Google Drive" +COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL="Keep Local Copy" +COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL_DESC="Keep the local backup file after uploading to remote storage" + +; FTP fields +COM_MOKOJOOMBACKUP_FIELD_FTP_HOST="FTP Host" +COM_MOKOJOOMBACKUP_FIELD_FTP_HOST_DESC="FTP server hostname or IP address" +COM_MOKOJOOMBACKUP_FIELD_FTP_PORT="FTP Port" +COM_MOKOJOOMBACKUP_FIELD_FTP_PORT_DESC="FTP server port (default: 21)" +COM_MOKOJOOMBACKUP_FIELD_FTP_USERNAME="FTP Username" +COM_MOKOJOOMBACKUP_FIELD_FTP_PASSWORD="FTP Password" +COM_MOKOJOOMBACKUP_FIELD_FTP_PATH="Remote Path" +COM_MOKOJOOMBACKUP_FIELD_FTP_PATH_DESC="Directory on the FTP server to upload backups to" +COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE="Passive Mode" +COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE_DESC="Use passive mode for FTP connections (recommended)" +COM_MOKOJOOMBACKUP_FIELD_FTP_SSL="Use FTPS (SSL)" +COM_MOKOJOOMBACKUP_FIELD_FTP_SSL_DESC="Connect using FTPS (FTP over SSL/TLS)" + +; Google Drive fields +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID="Google Client ID" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC="OAuth 2.0 Client ID from Google Cloud Console" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET="Google Client Secret" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN="Refresh Token" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC="OAuth 2.0 refresh token for offline access" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID="Drive Folder ID" +COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC="Google Drive folder ID where backups will be uploaded. Find this in the folder URL." + +; Backup types +COM_MOKOJOOMBACKUP_TYPE_FULL="Full Site (Database + Files)" +COM_MOKOJOOMBACKUP_TYPE_DATABASE="Database Only" +COM_MOKOJOOMBACKUP_TYPE_FILES="Files Only" +COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL="Differential (changed files + full DB)" + +; Status labels +COM_MOKOJOOMBACKUP_STATUS_COMPLETE="Complete" +COM_MOKOJOOMBACKUP_STATUS_RUNNING="Running" +COM_MOKOJOOMBACKUP_STATUS_FAIL="Failed" +COM_MOKOJOOMBACKUP_STATUS_PENDING="Pending" + +; Filters +COM_MOKOJOOMBACKUP_FILTER_SEARCH="Search" +COM_MOKOJOOMBACKUP_FILTER_STATUS="Status" +COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL="- Select Status -" + +; Tabs and fieldsets +COM_MOKOJOOMBACKUP_TAB_GENERAL="General" +COM_MOKOJOOMBACKUP_TAB_ARCHIVE="Archive Settings" +COM_MOKOJOOMBACKUP_TAB_FILTERS="Exclusion Filters" +COM_MOKOJOOMBACKUP_TAB_REMOTE="Remote Storage" +COM_MOKOJOOMBACKUP_FIELDSET_GENERAL="General" +COM_MOKOJOOMBACKUP_FIELDSET_ARCHIVE="Archive Settings" +COM_MOKOJOOMBACKUP_FIELDSET_STATUS="Status" +COM_MOKOJOOMBACKUP_FIELDSET_FILTERS="Exclusion Filters" +COM_MOKOJOOMBACKUP_FIELDSET_REMOTE="Remote Storage" +COM_MOKOJOOMBACKUP_FIELDSET_FTP="FTP Settings" +COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE="Google Drive Settings" + +; Backup profile selector +COM_MOKOJOOMBACKUP_BACKUP_PROFILE="Backup Profile" + +; Restore +COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE="Restore" +COM_MOKOJOOMBACKUP_RESTORE_CONFIRM="WARNING: Restoring will overwrite your current site files and/or database. Are you sure you want to continue?" + +; Notifications +COM_MOKOJOOMBACKUP_TAB_NOTIFICATIONS="Notifications" +COM_MOKOJOOMBACKUP_FIELDSET_NOTIFICATIONS="Email Notifications" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_EMAIL="Notification Email(s)" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses to notify. Leave empty to disable notifications." +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS="Notify on Success" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS_DESC="Send an email when a backup completes successfully." +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE="Notify on Failure" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC="Send an email when a backup fails. Includes log excerpt for debugging." + +; Integrity verification +COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY="Verify Integrity" +COM_MOKOJOOMBACKUP_VERIFY_OK="Archive integrity verified — SHA-256 checksum matches." +COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been modified or corrupted since backup." +COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified." + +; S3 storage +COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible" +COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings" +COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint" +COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL." +COM_MOKOJOOMBACKUP_FIELD_S3_REGION="Region" +COM_MOKOJOOMBACKUP_FIELD_S3_REGION_DESC="AWS region (e.g. us-east-1, eu-west-1). Required for AWS Signature V4." +COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY="Access Key" +COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY="Secret Key" +COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET="Bucket Name" +COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET_DESC="S3 bucket name where backups will be stored." +COM_MOKOJOOMBACKUP_FIELD_S3_PATH="Path Prefix" +COM_MOKOJOOMBACKUP_FIELD_S3_PATH_DESC="Optional path prefix inside the bucket (e.g. /backups or /sites/mysite)." + +; Akeeba Import +COM_MOKOJOOMBACKUP_TOOLBAR_IMPORT_AKEEBA="Import from Akeeba" +COM_MOKOJOOMBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba Backup Pro installed?" + +; Update site notice +COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your Update Site with your download key." +COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server." +COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." + +; Component Options (config.xml) +COM_MOKOJOOMBACKUP_CONFIG_GENERAL="General" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile." +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified." +COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice" +COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view." +COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults" +COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" +COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command." +COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count" +COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS="Notifications" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)." + +; Web Cron +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron" +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron" +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED_DESC="Allow backups to be triggered via a URL with a secret key. Use this when crontab is not available on shared hosting." +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET="Secret Word" +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET_DESC="The secret key required in the URL to trigger a backup. Use a long, random string. URL format: index.php?mokojoombackup_cron=YOUR_SECRET&profile_id=1" +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP="IP Whitelist" +COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP_DESC="Comma-separated list of IP addresses allowed to trigger web cron. Leave blank to allow any IP." + +; Folder picker +COM_MOKOJOOMBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOJOOMBACKUP_FOLDER_PLACEHOLDER="Uses placeholders (resolved at backup time)" +COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" + +; Exclude fields +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude." +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure" +COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name" + +; User group notifications +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above." + +; Dashboard warnings +COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root" +COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security." + +; Errors +COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted." +COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore." diff --git a/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.sys.ini b/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.sys.ini new file mode 100644 index 0000000..ec73f5c --- /dev/null +++ b/source/packages/com_mokojoombackup/language/en-GB/com_mokojoombackup.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_MOKOJOOMBACKUP="MokoJoomBackup" +COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." +COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles" diff --git a/src/packages/com_mokobackup/language/en-GB/index.html b/source/packages/com_mokojoombackup/language/en-GB/index.html similarity index 100% rename from src/packages/com_mokobackup/language/en-GB/index.html rename to source/packages/com_mokojoombackup/language/en-GB/index.html diff --git a/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.ini b/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.ini new file mode 100644 index 0000000..1c47e3a --- /dev/null +++ b/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.ini @@ -0,0 +1,69 @@ +; 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_MOKOJOOMBACKUP="MokoJoomBackup" +COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" +COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard" +COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles" +COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard" +COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup" +COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet" +COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled" +COM_MOKOJOOMBACKUP_DASHBOARD_NO_SCHEDULED="No tasks scheduled" +COM_MOKOJOOMBACKUP_DASHBOARD_TOTAL_BACKUPS="Total Backups" +COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE="Storage Used" +COM_MOKOJOOMBACKUP_DASHBOARD_FAILURES_7D="%d failures (7 days)" +COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions" +COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks" +COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site" +COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health" +COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" +COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" +COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" +COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." +COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your Update Site with your download key." +COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoJoomBackup update site not found. Reinstall the package to register the update server." +COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoJoomBackup installed successfully. Configure your Update Site to receive automatic updates." +COM_MOKOJOOMBACKUP_CONFIG_GENERAL="General" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR="Default Backup Directory" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC="Default directory for backup archives, relative to Joomla root. Can be overridden per profile." +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE="Default Profile" +COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC="Default backup profile used by quick actions and CLI when no profile is specified." +COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE="Show Update Site Notice" +COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC="Display the update site configuration notice on the Backup Records view." +COM_MOKOJOOMBACKUP_CONFIG_CLEANUP="Cleanup Defaults" +COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE="Max Backup Age (days)" +COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC="Default maximum age for backup records. Used by the system plugin and CLI cleanup command." +COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS="Max Backup Count" +COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS_DESC="Default maximum number of completed backups to retain." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS="Notifications" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL="Global Notification Email(s)" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL_DESC="Comma-separated list of email addresses for global backup notifications. Per-profile settings override this." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS="Notify on Success" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup completes successfully (unless overridden by profile)." +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure" +COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)." +COM_MOKOJOOMBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" +COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING_TITLE="Backup directory is inside the web root" +COM_MOKOJOOMBACKUP_DASHBOARD_DEFAULT_DIR_WARNING="One or more profiles store backups in the default directory inside the web root. This may expose backup archives if .htaccess is not supported. Move backups to a directory outside the web root for better security." +COM_MOKOJOOMBACKUP_FOLDER_EXISTS="Directory exists" +COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND="Directory not found" +COM_MOKOJOOMBACKUP_BACKUP_DIR_DEFAULT="Default (inside web root)" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_HELP="Check tables to exclude from database backup. Use Data to skip row data (keeps structure), Structure to skip CREATE TABLE, or both to fully exclude." +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data" +COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure" +COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name" +COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log" +COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum" +COM_MOKOJOOMBACKUP_FIELD_PATH="File Path" +COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size" +COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" +COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above." diff --git a/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.sys.ini b/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.sys.ini new file mode 100644 index 0000000..93b980a --- /dev/null +++ b/source/packages/com_mokojoombackup/language/en-US/com_mokojoombackup.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_MOKOJOOMBACKUP="MokoJoomBackup" +COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration." +COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records" +COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles" diff --git a/src/packages/com_mokobackup/language/en-US/index.html b/source/packages/com_mokojoombackup/language/en-US/index.html similarity index 100% rename from src/packages/com_mokobackup/language/en-US/index.html rename to source/packages/com_mokojoombackup/language/en-US/index.html diff --git a/src/packages/com_mokobackup/language/index.html b/source/packages/com_mokojoombackup/language/index.html similarity index 100% rename from src/packages/com_mokobackup/language/index.html rename to source/packages/com_mokojoombackup/language/index.html diff --git a/source/packages/com_mokojoombackup/mokojoombackup.xml b/source/packages/com_mokojoombackup/mokojoombackup.xml new file mode 100644 index 0000000..83c6fc6 --- /dev/null +++ b/source/packages/com_mokojoombackup/mokojoombackup.xml @@ -0,0 +1,66 @@ + + + + com_mokojoombackup + 01.01.21-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_MOKOJOOMBACKUP_DESCRIPTION + + Joomla\Component\MokoJoomBackup + + + + sql/install.mysql.sql + + + + + + sql/uninstall.mysql.sql + + + + + + sql/updates/mysql + + + + + COM_MOKOJOOMBACKUP + + COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD + COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS + COM_MOKOJOOMBACKUP_SUBMENU_PROFILES + + + cli + forms + services + sql + src + tmpl + + + en-GB/com_mokojoombackup.ini + en-GB/com_mokojoombackup.sys.ini + + + + + + src + + + diff --git a/src/packages/com_mokobackup/services/index.html b/source/packages/com_mokojoombackup/services/index.html similarity index 100% rename from src/packages/com_mokobackup/services/index.html rename to source/packages/com_mokojoombackup/services/index.html diff --git a/src/packages/com_mokobackup/services/provider.php b/source/packages/com_mokojoombackup/services/provider.php similarity index 82% rename from src/packages/com_mokobackup/services/provider.php rename to source/packages/com_mokojoombackup/services/provider.php index cd6bc5b..af40114 100644 --- a/src/packages/com_mokobackup/services/provider.php +++ b/source/packages/com_mokojoombackup/services/provider.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -15,20 +15,20 @@ 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\Component\MokoJoomBackup\Administrator\Extension\MokoJoomBackupComponent; 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->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\MokoJoomBackup')); + $container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\MokoJoomBackup')); $container->set( ComponentInterface::class, function (Container $container) { - $component = new MokoBackupComponent( + $component = new MokoJoomBackupComponent( $container->get(ComponentDispatcherFactoryInterface::class) ); $component->setMVCFactory($container->get(MVCFactoryInterface::class)); diff --git a/src/packages/com_mokobackup/sql/index.html b/source/packages/com_mokojoombackup/sql/index.html similarity index 100% rename from src/packages/com_mokobackup/sql/index.html rename to source/packages/com_mokojoombackup/sql/index.html diff --git a/src/packages/com_mokobackup/sql/install.mysql.sql b/source/packages/com_mokojoombackup/sql/install.mysql.sql similarity index 82% rename from src/packages/com_mokobackup/sql/install.mysql.sql rename to source/packages/com_mokojoombackup/sql/install.mysql.sql index fe7c580..47a5427 100644 --- a/src/packages/com_mokobackup/sql/install.mysql.sql +++ b/source/packages/com_mokojoombackup/sql/install.mysql.sql @@ -1,4 +1,4 @@ -CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( +CREATE TABLE IF NOT EXISTS `#__mokojoombackup_profiles` ( `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `title` VARCHAR(255) NOT NULL DEFAULT '', `description` TEXT NOT NULL, @@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `archive_format` VARCHAR(10) NOT NULL DEFAULT 'zip', `compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max', `split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part', - `backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokobackup/backups', + `backup_dir` VARCHAR(512) NOT NULL DEFAULT 'administrator/components/com_mokojoombackup/backups', + `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders', `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', @@ -30,8 +31,9 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( `s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', - `include_kickstart` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include standalone restore.php in archive', + `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive', `notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails', + `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs', `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, `notify_on_failure` TINYINT(1) NOT NULL DEFAULT 1, `published` TINYINT(1) NOT NULL DEFAULT 1, @@ -42,7 +44,7 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_profiles` ( KEY `idx_published` (`published`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( +CREATE TABLE IF NOT EXISTS `#__mokojoombackup_records` ( `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1, `description` VARCHAR(255) NOT NULL DEFAULT '', @@ -63,24 +65,24 @@ CREATE TABLE IF NOT EXISTS `#__mokobackup_records` ( `remote_filename` VARCHAR(512) NOT NULL DEFAULT '', `checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'SHA-256 hash of archive', `base_record_id` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Base full backup ID for differential', - `manifest` LONGTEXT NOT NULL COMMENT 'JSON file manifest for differential comparison', - `log` MEDIUMTEXT NOT NULL COMMENT 'Step-by-step backup log', + `manifest` LONGTEXT DEFAULT NULL COMMENT 'JSON file manifest for differential comparison', + `log` MEDIUMTEXT DEFAULT 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` ( +-- Insert default backup profile (IGNORE prevents duplicate key error on update) +INSERT IGNORE INTO `#__mokojoombackup_profiles` ( `id`, `title`, `description`, `backup_type`, `archive_format`, `compression_level`, `split_size`, `backup_dir`, `exclude_dirs`, `exclude_files`, `exclude_tables`, `published`, `ordering`, `created`, `modified` ) VALUES ( 1, 'Default Backup Profile', 'Full site backup with default settings', 'full', - 'zip', 5, 0, 'administrator/components/com_mokobackup/backups', - 'administrator/components/com_mokobackup/backups\ntmp\ncache\nlogs\nadministrator/logs', + 'zip', 5, 0, 'administrator/components/com_mokojoombackup/backups', + 'administrator/components/com_mokojoombackup/backups\ntmp\ncache\nlogs\nadministrator/logs', '.gitignore\n.htaccess.bak', '#__session', 1, 1, NOW(), NOW() diff --git a/src/packages/com_mokobackup/sql/mysql/index.html b/source/packages/com_mokojoombackup/sql/mysql/index.html similarity index 100% rename from src/packages/com_mokobackup/sql/mysql/index.html rename to source/packages/com_mokojoombackup/sql/mysql/index.html diff --git a/source/packages/com_mokojoombackup/sql/uninstall.mysql.sql b/source/packages/com_mokojoombackup/sql/uninstall.mysql.sql new file mode 100644 index 0000000..974f591 --- /dev/null +++ b/source/packages/com_mokojoombackup/sql/uninstall.mysql.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `#__mokojoombackup_records`; +DROP TABLE IF EXISTS `#__mokojoombackup_profiles`; diff --git a/src/packages/com_mokobackup/sql/updates/index.html b/source/packages/com_mokojoombackup/sql/updates/index.html similarity index 100% rename from src/packages/com_mokobackup/sql/updates/index.html rename to source/packages/com_mokojoombackup/sql/updates/index.html diff --git a/src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql b/source/packages/com_mokojoombackup/sql/updates/mysql/01.00.00.sql similarity index 100% rename from src/packages/com_mokobackup/sql/updates/mysql/01.00.00.sql rename to source/packages/com_mokojoombackup/sql/updates/mysql/01.00.00.sql diff --git a/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.01.sql b/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.01.sql new file mode 100644 index 0000000..ec8fb68 --- /dev/null +++ b/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.01.sql @@ -0,0 +1 @@ +ALTER TABLE `#__mokojoombackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive'; diff --git a/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.02.sql b/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.02.sql new file mode 100644 index 0000000..8b86fb7 --- /dev/null +++ b/source/packages/com_mokojoombackup/sql/updates/mysql/01.01.02.sql @@ -0,0 +1,12 @@ +-- MokoJoomBackup 01.01.02 +-- Consolidated schema updates: NULL defaults, notifications, archive name format + +-- Fix: allow NULL defaults for manifest and log columns +ALTER TABLE `#__mokojoombackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL; +ALTER TABLE `#__mokojoombackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL; + +-- Add user group notifications column to profiles +ALTER TABLE `#__mokojoombackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`; + +-- Add archive_name_format column with placeholder support +ALTER TABLE `#__mokojoombackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`; diff --git a/src/packages/com_mokobackup/sql/updates/mysql/index.html b/source/packages/com_mokojoombackup/sql/updates/mysql/index.html similarity index 100% rename from src/packages/com_mokobackup/sql/updates/mysql/index.html rename to source/packages/com_mokojoombackup/sql/updates/mysql/index.html diff --git a/source/packages/com_mokojoombackup/src/Controller/AjaxController.php b/source/packages/com_mokojoombackup/src/Controller/AjaxController.php new file mode 100644 index 0000000..e08da9f --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Controller/AjaxController.php @@ -0,0 +1,210 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * AJAX controller for step-based backups. + * Handles init and step requests from the admin UI JavaScript. + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\SteppedBackupEngine; + +class AjaxController extends BaseController +{ + /** + * Initialize a new stepped backup. + * POST: task=ajax.init&profile_id=1&description=... + */ + public function init(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $profileId = $this->input->getInt('profile_id', 1); + $description = $this->input->getString('description', ''); + + $engine = new SteppedBackupEngine(); + $result = $engine->init($profileId, $description, 'backend'); + + $this->sendJson($result); + } + + /** + * Run the next step of a backup session. + * POST: task=ajax.step&session_id=mb_... + */ + public function step(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $sessionId = $this->input->getString('session_id', ''); + + if (empty($sessionId)) { + $this->sendJson(['error' => true, 'message' => 'Missing session_id']); + + return; + } + + $engine = new SteppedBackupEngine(); + $result = $engine->runStep($sessionId); + + $this->sendJson($result); + } + + /** + * Browse server directories for the folder picker field. + * POST: task=ajax.browseDir&path=/some/path + */ + public function browseDir(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $requestPath = $this->input->getString('path', JPATH_ROOT); + $path = realpath($requestPath) ?: $requestPath; + + // Security: restrict browsing to site root and current user's home + $jRoot = realpath(JPATH_ROOT); + $homeDir = getenv('HOME') ?: (getenv('USERPROFILE') ?: ''); + $allowed = false; + + if ($jRoot !== false && strpos($path, $jRoot) === 0) { + $allowed = true; + } elseif ($homeDir !== '' && strpos($path, $homeDir) === 0) { + $allowed = true; + } + + if (!$allowed) { + $this->sendJson(['error' => true, 'message' => 'Access denied: path outside allowed directories']); + return; + } + + if (!is_dir($path)) { + $this->sendJson(['error' => true, 'message' => 'Directory not found: ' . $path]); + + return; + } + + // Security: only allow browsing within JPATH_ROOT or parent directories + // that could contain a backup folder (e.g., /home/user/backups) + $dirs = []; + $handle = @opendir($path); + + if ($handle) { + while (($entry = readdir($handle)) !== false) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $fullPath = $path . '/' . $entry; + + if (is_dir($fullPath) && $entry[0] !== '.') { + $dirs[] = [ + 'name' => $entry, + 'path' => $fullPath, + ]; + } + } + + closedir($handle); + } + + usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + + $parent = dirname($path); + + $this->sendJson([ + 'error' => false, + 'current' => $path, + 'parent' => ($parent !== $path) ? $parent : null, + 'dirs' => $dirs, + ]); + } + + /** + * Load and return the log file contents for a backup record. + * POST: task=ajax.viewLog&id=123 + */ + public function viewLog(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token']); + + return; + } + + $id = $this->input->getInt('id', 0); + + if (!$id) { + $this->sendJson(['error' => true, 'message' => 'Missing record ID']); + + return; + } + + $db = \Joomla\CMS\Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['absolute_path', 'log'])) + ->from($db->quoteName('#__mokojoombackup_records')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + $this->sendJson(['error' => true, 'message' => 'Record not found']); + + return; + } + + // Try to load log from file alongside the archive + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $record->absolute_path); + $logContent = ''; + + if (is_file($logPath)) { + $logContent = file_get_contents($logPath); + } elseif (!empty($record->log)) { + // Fall back to database-stored log + $logContent = $record->log; + } + + $this->sendJson([ + 'error' => false, + 'log' => $logContent ?: '(no log available)', + 'source' => is_file($logPath) ? 'file' : 'database', + ]); + } + + /** + * Send a JSON response and close the application. + */ + private function sendJson(array $data): void + { + $app = $this->app; + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + $app->sendHeaders(); + + echo json_encode($data); + + $app->close(); + } +} diff --git a/src/packages/com_mokobackup/src/Controller/BackupController.php b/source/packages/com_mokojoombackup/src/Controller/BackupController.php similarity index 70% rename from src/packages/com_mokobackup/src/Controller/BackupController.php rename to source/packages/com_mokojoombackup/src/Controller/BackupController.php index 459fe3f..7844749 100644 --- a/src/packages/com_mokobackup/src/Controller/BackupController.php +++ b/source/packages/com_mokojoombackup/src/Controller/BackupController.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; @@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\FormController; class BackupController extends FormController { - protected $text_prefix = 'COM_MOKOBACKUP_BACKUP'; + protected $text_prefix = 'COM_MOKOJOOMBACKUP_BACKUP'; } diff --git a/src/packages/com_mokobackup/src/Controller/BackupsController.php b/source/packages/com_mokojoombackup/src/Controller/BackupsController.php similarity index 55% rename from src/packages/com_mokobackup/src/Controller/BackupsController.php rename to source/packages/com_mokojoombackup/src/Controller/BackupsController.php index 0ad0490..c1a99ca 100644 --- a/src/packages/com_mokobackup/src/Controller/BackupsController.php +++ b/source/packages/com_mokojoombackup/src/Controller/BackupsController.php @@ -2,24 +2,24 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; -use Joomla\Component\MokoBackup\Administrator\Engine\BackupEngine; -use Joomla\Component\MokoBackup\Administrator\Engine\RestoreEngine; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\RestoreEngine; class BackupsController extends AdminController { - protected $text_prefix = 'COM_MOKOBACKUP_BACKUPS'; + protected $text_prefix = 'COM_MOKOJOOMBACKUP_BACKUPS'; public function getModel($name = 'Backup', $prefix = 'Administrator', $config = ['ignore_request' => true]) { @@ -47,7 +47,7 @@ class BackupsController extends AdminController $this->setMessage($result['message'], 'error'); } - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); } /** @@ -62,23 +62,34 @@ class BackupsController extends AdminController $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)); + $this->setMessage('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&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(); + // Flush any output buffers to prevent HTML mixing with binary data + while (@ob_end_clean()) { + // clear all buffers + } + + $filename = basename($item->archivename); + $filesize = filesize($item->absolute_path); + + // Detect content type from file extension + $contentType = str_ends_with($filename, '.tar.gz') + ? 'application/gzip' + : 'application/zip'; + + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . $filesize); + header('Cache-Control: no-cache, must-revalidate'); + header('Pragma: no-cache'); readfile($item->absolute_path); - $app->close(); + $this->app->close(); } /** @@ -97,8 +108,8 @@ class BackupsController extends AdminController $password = $this->input->getString('encryption_password', ''); if (!$id) { - $this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); return; } @@ -112,7 +123,7 @@ class BackupsController extends AdminController $this->setMessage($result['message'], 'error'); } - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); } /** @@ -126,8 +137,8 @@ class BackupsController extends AdminController $id = !empty($cid) ? (int) $cid[0] : $this->input->getInt('id', 0); if (!$id) { - $this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); return; } @@ -136,22 +147,22 @@ class BackupsController extends AdminController $item = $model->getItem($id); if (!$item || !$item->id) { - $this->setMessage('COM_MOKOBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); return; } if (!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)); + $this->setMessage('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); return; } if (empty($item->checksum)) { - $this->setMessage('COM_MOKOBACKUP_VERIFY_NO_CHECKSUM', 'warning'); - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM', 'warning'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); return; } @@ -159,11 +170,11 @@ class BackupsController extends AdminController $currentHash = hash_file('sha256', $item->absolute_path); if ($currentHash === $item->checksum) { - $this->setMessage('COM_MOKOBACKUP_VERIFY_OK'); + $this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_OK'); } else { - $this->setMessage('COM_MOKOBACKUP_VERIFY_FAILED', 'error'); + $this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_FAILED', 'error'); } - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=backups', false)); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false)); } } diff --git a/src/packages/com_mokobackup/src/Controller/DisplayController.php b/source/packages/com_mokojoombackup/src/Controller/DisplayController.php similarity index 72% rename from src/packages/com_mokobackup/src/Controller/DisplayController.php rename to source/packages/com_mokojoombackup/src/Controller/DisplayController.php index 5e4ec11..cac40fd 100644 --- a/src/packages/com_mokobackup/src/Controller/DisplayController.php +++ b/source/packages/com_mokojoombackup/src/Controller/DisplayController.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; @@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\BaseController; class DisplayController extends BaseController { - protected $default_view = 'backups'; + protected $default_view = 'dashboard'; } diff --git a/src/packages/com_mokobackup/src/Controller/ProfileController.php b/source/packages/com_mokojoombackup/src/Controller/ProfileController.php similarity index 70% rename from src/packages/com_mokobackup/src/Controller/ProfileController.php rename to source/packages/com_mokojoombackup/src/Controller/ProfileController.php index 5a84e2e..d7540e8 100644 --- a/src/packages/com_mokobackup/src/Controller/ProfileController.php +++ b/source/packages/com_mokojoombackup/src/Controller/ProfileController.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; @@ -16,5 +16,5 @@ use Joomla\CMS\MVC\Controller\FormController; class ProfileController extends FormController { - protected $text_prefix = 'COM_MOKOBACKUP_PROFILE'; + protected $text_prefix = 'COM_MOKOJOOMBACKUP_PROFILE'; } diff --git a/src/packages/com_mokobackup/src/Controller/ProfilesController.php b/source/packages/com_mokojoombackup/src/Controller/ProfilesController.php similarity index 85% rename from src/packages/com_mokobackup/src/Controller/ProfilesController.php rename to source/packages/com_mokojoombackup/src/Controller/ProfilesController.php index 9470eb4..fa0ad47 100644 --- a/src/packages/com_mokobackup/src/Controller/ProfilesController.php +++ b/source/packages/com_mokojoombackup/src/Controller/ProfilesController.php @@ -2,24 +2,24 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Controller; defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; -use Joomla\Component\MokoBackup\Administrator\Engine\AkeebaImporter; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\AkeebaImporter; class ProfilesController extends AdminController { - protected $text_prefix = 'COM_MOKOBACKUP_PROFILES'; + protected $text_prefix = 'COM_MOKOJOOMBACKUP_PROFILES'; public function getModel($name = 'Profile', $prefix = 'Administrator', $config = ['ignore_request' => true]) { @@ -39,8 +39,8 @@ class ProfilesController extends AdminController $detection = $importer->detect(); if (!$detection['profiles']) { - $this->setMessage('COM_MOKOBACKUP_AKEEBA_NOT_FOUND', 'error'); - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=profiles', false)); + $this->setMessage('COM_MOKOJOOMBACKUP_AKEEBA_NOT_FOUND', 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=profiles', false)); return; } @@ -55,7 +55,7 @@ class ProfilesController extends AdminController $this->setMessage($result['message'], 'error'); } - $this->setRedirect(Route::_('index.php?option=com_mokobackup&view=profiles', false)); + $this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=profiles', false)); } /** diff --git a/src/packages/com_mokobackup/src/Controller/index.html b/source/packages/com_mokojoombackup/src/Controller/index.html similarity index 100% rename from src/packages/com_mokobackup/src/Controller/index.html rename to source/packages/com_mokojoombackup/src/Controller/index.html diff --git a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php b/source/packages/com_mokojoombackup/src/Engine/AkeebaImporter.php similarity index 96% rename from src/packages/com_mokobackup/src/Engine/AkeebaImporter.php rename to source/packages/com_mokojoombackup/src/Engine/AkeebaImporter.php index cafa191..c1fab5b 100644 --- a/src/packages/com_mokobackup/src/Engine/AkeebaImporter.php +++ b/source/packages/com_mokojoombackup/src/Engine/AkeebaImporter.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -25,7 +25,7 @@ * "databases": {"include": {...}, "exclude": {...}}} */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -119,7 +119,7 @@ class AkeebaImporter $akProfiles = $db->loadObjectList(); $profilesImported = 0; - $profileIdMap = []; // akeeba_id => mokobackup_id + $profileIdMap = []; // akeeba_id => mokojoombackup_id foreach ($akProfiles as $akProfile) { $config = $this->parseAkeebaConfig($akProfile->configuration ?? ''); @@ -127,11 +127,11 @@ class AkeebaImporter $mokoProfile = $this->mapToMokoProfile($akProfile, $config, $filters); - $db->insertObject('#__mokobackup_profiles', $mokoProfile, 'id'); + $db->insertObject('#__mokojoombackup_profiles', $mokoProfile, 'id'); $profileIdMap[$akProfile->id] = $mokoProfile->id; $profilesImported++; - $this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoBackup #' . $mokoProfile->id . ')'); + $this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoJoomBackup #' . $mokoProfile->id . ')'); } // Import backup history @@ -200,7 +200,7 @@ class AkeebaImporter 'log' => 'Imported from Akeeba Backup record #' . $stat->id, ]; - $db->insertObject('#__mokobackup_records', $record, 'id'); + $db->insertObject('#__mokojoombackup_records', $record, 'id'); $imported++; } @@ -246,7 +246,7 @@ class AkeebaImporter 's3_bucket' => $config['engine.postproc.s3.bucket'] ?? '', 's3_path' => $config['engine.postproc.s3.directory'] ?? '/backups', 'remote_keep_local' => 1, - 'include_kickstart' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), + 'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'), 'published' => 1, 'ordering' => (int) $akProfile->id, 'created' => $now, @@ -484,7 +484,7 @@ class AkeebaImporter $dir = $config['akeeba.basic.output_directory'] ?? ''; if (empty($dir) || $dir === '[DEFAULT_OUTPUT]') { - return 'administrator/components/com_mokobackup/backups'; + return 'administrator/components/com_mokojoombackup/backups'; } // Convert absolute path to relative @@ -492,7 +492,7 @@ class AkeebaImporter $dir = ltrim(substr($dir, strlen(JPATH_ROOT)), '/\\'); } - return $dir ?: 'administrator/components/com_mokobackup/backups'; + return $dir ?: 'administrator/components/com_mokojoombackup/backups'; } private function mapRemoteStorage(array $config): string diff --git a/source/packages/com_mokojoombackup/src/Engine/ArchiverInterface.php b/source/packages/com_mokojoombackup/src/Engine/ArchiverInterface.php new file mode 100644 index 0000000..b496059 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Engine/ArchiverInterface.php @@ -0,0 +1,41 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +interface ArchiverInterface +{ + /** + * Open or create the archive at the given path. + */ + public function open(string $path): void; + + /** + * Add a string as a file inside the archive. + */ + public function addFromString(string $localName, string $contents): void; + + /** + * Add a file from disk into the archive. + */ + public function addFile(string $filePath, string $localName): void; + + /** + * Finalize and close the archive. + */ + public function close(): void; + + /** + * Return the file extension for this archive type (e.g. 'zip', 'tar.gz'). + */ + public function getExtension(): string; +} diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php similarity index 73% rename from src/packages/com_mokobackup/src/Engine/BackupEngine.php rename to source/packages/com_mokojoombackup/src/Engine/BackupEngine.php index 923c6d4..62aa3eb 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokojoombackup/src/Engine/BackupEngine.php @@ -2,17 +2,18 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class BackupEngine { @@ -45,7 +46,7 @@ class BackupEngine // Load profile $query = $db->getQuery(true) ->select('*') - ->from($db->quoteName('#__mokobackup_profiles')) + ->from($db->quoteName('#__mokojoombackup_profiles')) ->where($db->quoteName('id') . ' = ' . $profileId); $db->setQuery($query); $profile = $db->loadObject(); @@ -59,18 +60,26 @@ class BackupEngine $excludeFiles = $this->parseNewlineList($profile->exclude_files ?? ''); $excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); - // Determine backup directory - $this->backupDir = JPATH_ROOT . '/' . ($profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'); + // Resolve placeholders in directory and filename + $resolver = new PlaceholderResolver($profile); + + $configuredDir = $profile->backup_dir ?: 'administrator/components/com_mokojoombackup/backups'; + $this->backupDir = $this->resolveBackupDir($resolver->resolve($configuredDir)); if (!is_dir($this->backupDir)) { - mkdir($this->backupDir, 0755, true); + if (!mkdir($this->backupDir, 0755, true)) { + return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0]; + } } // Create backup record - $now = date('Y-m-d H:i:s'); - $tag = date('Ymd_His'); - $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); - $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + $now = date('Y-m-d H:i:s'); + $tag = $resolver->getTag(); + $archiveFormat = $profile->archive_format ?? 'zip'; + $archiver = $this->createArchiver($archiveFormat); + $archiveExt = $archiver->getExtension(); + $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt; if (empty($description)) { $description = $profile->title . ' — ' . $now; @@ -97,19 +106,15 @@ class BackupEngine 'log' => '', ]; - $db->insertObject('#__mokobackup_records', $record, 'id'); + $db->insertObject('#__mokojoombackup_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); - } + // Create archive + $archiver->open($archivePath); $dbSize = 0; $filesCount = 0; @@ -120,7 +125,7 @@ class BackupEngine $this->log('Starting database dump...'); $dumper = new DatabaseDumper($excludeTables); $sqlDump = $dumper->dump(); - $zip->addFromString('database.sql', $sqlDump); + $archiver->addFromString('database.sql', $sqlDump); $dbSize = strlen($sqlDump); $tablesCount = $dumper->getTablesCount(); $this->log('Database dump complete: ' . $tablesCount . ' tables, ' . number_format($dbSize) . ' bytes'); @@ -152,14 +157,22 @@ class BackupEngine $filesCount = count($filesToBackup); $this->log('Backing up ' . $filesCount . ' files'); + $skippedFiles = 0; + foreach ($filesToBackup as $relativePath) { $fullPath = JPATH_ROOT . '/' . $relativePath; if (is_file($fullPath) && is_readable($fullPath)) { - $zip->addFile($fullPath, $relativePath); + $archiver->addFile($fullPath, $relativePath); + } else { + $skippedFiles++; } } + if ($skippedFiles > 0) { + $this->log('WARNING: ' . $skippedFiles . ' files skipped (not readable or missing)'); + } + $this->log('Files added to archive'); // Build manifest for full/differential backups (used by future differentials) @@ -169,15 +182,19 @@ class BackupEngine } } - $zip->close(); + $archiver->close(); // Step 1.5: Apply AES-256 encryption (if configured) $encryptionPassword = $profile->encryption_password ?? ''; if (!empty($encryptionPassword)) { - $this->log('Encrypting archive with AES-256...'); - $this->encryptArchive($archivePath, $encryptionPassword); - $this->log('Archive encrypted'); + if ($archiveFormat !== 'zip') { + $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption'); + } else { + $this->log('Encrypting archive with AES-256...'); + $this->encryptArchive($archivePath, $encryptionPassword); + $this->log('Archive encrypted'); + } } // Record archive size and compute checksum (after encryption) @@ -187,21 +204,21 @@ class BackupEngine $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); - // Step 2.5: Wrap with Kickstart restore script (if enabled) - $includeKickstart = (bool) ($profile->include_kickstart ?? false); + // Step 2.5: Wrap with MokoRestore script (if enabled) + $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); - if ($includeKickstart) { - $this->log('Wrapping with Kickstart restore script...'); - $kickstartName = str_replace('.zip', '-kickstart.zip', $archiveName); - $kickstartPath = $this->backupDir . '/' . $kickstartName; - Kickstart::wrap($archivePath, $kickstartPath); + if ($includeMokoRestore) { + $this->log('Wrapping with MokoRestore script...'); + $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); + $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; + MokoRestore::wrap($archivePath, $mokoRestorePath); // Replace the original archive with the wrapped one @unlink($archivePath); - rename($kickstartPath, $archivePath); + rename($mokoRestorePath, $archivePath); $totalSize = filesize($archivePath); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; - $this->log('Kickstart archive created: ' . $sizeHuman); + $this->log('MokoRestore archive created: ' . $sizeHuman); } $remoteFilename = ''; @@ -229,6 +246,13 @@ class BackupEngine } } + // Write log file alongside the archive + $logContent = implode("\n", $this->log); + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath); + if (@file_put_contents($logPath, $logContent) === false) { + error_log('MokoJoomBackup: Could not write log file: ' . $logPath); + } + // Final record update $update = (object) [ 'id' => $recordId, @@ -242,14 +266,17 @@ class BackupEngine 'remote_filename' => $remoteFilename, 'checksum' => $checksum, 'manifest' => !empty($manifest) ? json_encode($manifest) : '', - 'log' => implode("\n", $this->log), + 'log' => $logContent, ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); // Send success notification NotificationSender::send($profile, $update, true, implode("\n", $this->log)); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); + return [ 'success' => true, 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', @@ -270,11 +297,14 @@ class BackupEngine 'log' => implode("\n", $this->log), ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); // Send failure notification NotificationSender::send($profile, $update, false, implode("\n", $this->log)); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin); + return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId]; } } @@ -354,6 +384,18 @@ class BackupEngine return true; } + /** + * Create the appropriate archiver based on the archive format. + */ + private function createArchiver(string $format): ArchiverInterface + { + return match ($format) { + 'zip' => new ZipArchiver(), + 'tar.gz' => new TarGzArchiver(), + default => new ZipArchiver(), + }; + } + /** * Create the appropriate remote uploader based on the storage type. */ @@ -375,7 +417,7 @@ class BackupEngine { $query = $db->getQuery(true) ->select($db->quoteName('manifest')) - ->from($db->quoteName('#__mokobackup_records')) + ->from($db->quoteName('#__mokojoombackup_records')) ->where($db->quoteName('profile_id') . ' = ' . $profileId) ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) ->where($db->quoteName('manifest') . ' != ' . $db->quote('')) @@ -445,6 +487,42 @@ class BackupEngine )); } + /** + * Dispatch the onMokoJoomBackupAfterRun event so plugins (actionlog, etc.) can react. + */ + private function dispatchAfterRun(bool $success, int $recordId, string $description, int $profileId, string $origin): void + { + try { + $app = Factory::getApplication(); + + $event = new Event('onMokoJoomBackupAfterRun', [ + 'success' => $success, + 'record_id' => $recordId, + 'description' => $description, + 'profile_id' => $profileId, + 'origin' => $origin, + ]); + + $app->getDispatcher()->dispatch('onMokoJoomBackupAfterRun', $event); + } catch (\Throwable $e) { + // Never let a listener failure break the backup result, but log it + error_log('MokoJoomBackup: onAfterRun listener error: ' . $e->getMessage()); + } + } + + /** + * Resolve a backup directory path. Absolute paths are used as-is, + * relative paths are resolved from JPATH_ROOT. + */ + private function resolveBackupDir(string $dir): string + { + if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) { + return rtrim($dir, '/\\'); + } + + return JPATH_ROOT . '/' . $dir; + } + 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/source/packages/com_mokojoombackup/src/Engine/DatabaseDumper.php similarity index 53% rename from src/packages/com_mokobackup/src/Engine/DatabaseDumper.php rename to source/packages/com_mokojoombackup/src/Engine/DatabaseDumper.php index 3c81269..f4bf538 100644 --- a/src/packages/com_mokobackup/src/Engine/DatabaseDumper.php +++ b/source/packages/com_mokojoombackup/src/Engine/DatabaseDumper.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -16,15 +16,33 @@ use Joomla\CMS\Factory; class DatabaseDumper { - private array $excludeTables; + /** @var array Tables to exclude entirely (both structure and data) */ + private array $excludeBoth = []; + + /** @var array Tables to exclude data only (structure is kept) */ + private array $excludeDataOnly = []; + + /** @var array Tables to exclude structure only (data is kept — unusual) */ + private array $excludeStructureOnly = []; + private int $tablesCount = 0; /** - * @param array $excludeTables Table names to exclude (with #__ prefix) + * @param array $excludeTables Table names to exclude (with #__ prefix). + * Supports suffixes: :data-only, :structure-only. + * No suffix = exclude both (backward compatible). */ public function __construct(array $excludeTables = []) { - $this->excludeTables = $excludeTables; + foreach ($excludeTables as $entry) { + if (str_ends_with($entry, ':data-only')) { + $this->excludeDataOnly[] = substr($entry, 0, -10); + } elseif (str_ends_with($entry, ':structure-only')) { + $this->excludeStructureOnly[] = substr($entry, 0, -15); + } else { + $this->excludeBoth[] = $entry; + } + } } /** @@ -62,29 +80,49 @@ class DatabaseDumper // Check if excluded $abstractName = '#__' . substr($table, strlen($prefix)); - if ($this->isExcluded($abstractName, $table)) { + if ($this->isExcludedBoth($abstractName, $table)) { continue; } + $skipData = $this->isExcludedDataOnly($abstractName, $table); + $skipStructure = $this->isExcludedStructureOnly($abstractName, $table); + $this->tablesCount++; - // Get CREATE TABLE statement - $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); - $createRow = $db->loadRow(); + $output[] = '-- --------------------------------------------------------'; + $output[] = '-- Table: ' . $table; - if (!$createRow || empty($createRow[1])) { - continue; + if ($skipData) { + $output[] = '-- (data excluded)'; + } + + if ($skipStructure) { + $output[] = '-- (structure excluded)'; } $output[] = '-- --------------------------------------------------------'; - $output[] = '-- Table: ' . $table; - $output[] = '-- --------------------------------------------------------'; - $output[] = ''; - $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; - $output[] = $createRow[1] . ';'; $output[] = ''; - // Dump data in chunks + // Get CREATE TABLE statement (unless structure is excluded) + if (!$skipStructure) { + $db->setQuery('SHOW CREATE TABLE ' . $db->quoteName($table)); + $createRow = $db->loadRow(); + + if (!$createRow || empty($createRow[1])) { + continue; + } + + $output[] = 'DROP TABLE IF EXISTS ' . $db->quoteName($table) . ';'; + $output[] = $createRow[1] . ';'; + $output[] = ''; + } + + // Dump data (unless data is excluded) + if ($skipData) { + $output[] = ''; + continue; + } + $db->setQuery('SELECT COUNT(*) FROM ' . $db->quoteName($table)); $rowCount = (int) $db->loadResult(); @@ -135,11 +173,39 @@ class DatabaseDumper } /** - * Check if a table is excluded. + * Check if a table is fully excluded (both data and structure). */ - private function isExcluded(string $abstractName, string $realName): bool + private function isExcludedBoth(string $abstractName, string $realName): bool { - foreach ($this->excludeTables as $pattern) { + foreach ($this->excludeBoth as $pattern) { + if ($pattern === $abstractName || $pattern === $realName) { + return true; + } + } + + return false; + } + + /** + * Check if a table's data is excluded (structure only). + */ + private function isExcludedDataOnly(string $abstractName, string $realName): bool + { + foreach ($this->excludeDataOnly as $pattern) { + if ($pattern === $abstractName || $pattern === $realName) { + return true; + } + } + + return false; + } + + /** + * Check if a table's structure is excluded (data only). + */ + private function isExcludedStructureOnly(string $abstractName, string $realName): bool + { + foreach ($this->excludeStructureOnly as $pattern) { if ($pattern === $abstractName || $pattern === $realName) { return true; } diff --git a/src/packages/com_mokobackup/src/Engine/DatabaseImporter.php b/source/packages/com_mokojoombackup/src/Engine/DatabaseImporter.php similarity index 92% rename from src/packages/com_mokobackup/src/Engine/DatabaseImporter.php rename to source/packages/com_mokojoombackup/src/Engine/DatabaseImporter.php index ec806d6..4e178de 100644 --- a/src/packages/com_mokobackup/src/Engine/DatabaseImporter.php +++ b/source/packages/com_mokojoombackup/src/Engine/DatabaseImporter.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -12,7 +12,7 @@ * and DROP TABLE before CREATE TABLE for clean restores. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -101,7 +101,7 @@ class DatabaseImporter // Log but don't abort — some statements may fail on // different MySQL versions (e.g. charset differences) // but the overall restore should continue. - error_log('MokoBackup SQL import warning: ' . $e->getMessage()); + error_log('MokoJoomBackup SQL import warning: ' . $e->getMessage()); } } } @@ -115,7 +115,7 @@ class DatabaseImporter $db->execute(); $statementsExecuted++; } catch (\Exception $e) { - error_log('MokoBackup SQL import warning (final): ' . $e->getMessage()); + error_log('MokoJoomBackup SQL import warning (final): ' . $e->getMessage()); } } } finally { diff --git a/src/packages/com_mokobackup/src/Engine/DifferentialScanner.php b/source/packages/com_mokojoombackup/src/Engine/DifferentialScanner.php similarity index 96% rename from src/packages/com_mokobackup/src/Engine/DifferentialScanner.php rename to source/packages/com_mokojoombackup/src/Engine/DifferentialScanner.php index ce477f5..0996b9b 100644 --- a/src/packages/com_mokobackup/src/Engine/DifferentialScanner.php +++ b/source/packages/com_mokojoombackup/src/Engine/DifferentialScanner.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -15,7 +15,7 @@ * {"path/to/file": {"size": 1234, "mtime": 1717350000}, ...} */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/FileRestorer.php b/source/packages/com_mokojoombackup/src/Engine/FileRestorer.php similarity index 96% rename from src/packages/com_mokobackup/src/Engine/FileRestorer.php rename to source/packages/com_mokojoombackup/src/Engine/FileRestorer.php index fc2e72a..0bad937 100644 --- a/src/packages/com_mokobackup/src/Engine/FileRestorer.php +++ b/source/packages/com_mokojoombackup/src/Engine/FileRestorer.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -11,7 +11,7 @@ * Skips database.sql and sensitive files that should not be overwritten. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/FileScanner.php b/source/packages/com_mokojoombackup/src/Engine/FileScanner.php similarity index 96% rename from src/packages/com_mokobackup/src/Engine/FileScanner.php rename to source/packages/com_mokojoombackup/src/Engine/FileScanner.php index aaa0577..f64884a 100644 --- a/src/packages/com_mokobackup/src/Engine/FileScanner.php +++ b/source/packages/com_mokojoombackup/src/Engine/FileScanner.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/FtpUploader.php b/source/packages/com_mokojoombackup/src/Engine/FtpUploader.php similarity index 97% rename from src/packages/com_mokobackup/src/Engine/FtpUploader.php rename to source/packages/com_mokojoombackup/src/Engine/FtpUploader.php index b370f34..9d585b3 100644 --- a/src/packages/com_mokobackup/src/Engine/FtpUploader.php +++ b/source/packages/com_mokojoombackup/src/Engine/FtpUploader.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php b/source/packages/com_mokojoombackup/src/Engine/GoogleDriveUploader.php similarity index 98% rename from src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php rename to source/packages/com_mokojoombackup/src/Engine/GoogleDriveUploader.php index 1197ec6..3cf9c80 100644 --- a/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php +++ b/source/packages/com_mokojoombackup/src/Engine/GoogleDriveUploader.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -12,7 +12,7 @@ * No SDK dependency — pure PHP with cURL. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php b/source/packages/com_mokojoombackup/src/Engine/JpaUnarchiver.php similarity index 98% rename from src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php rename to source/packages/com_mokojoombackup/src/Engine/JpaUnarchiver.php index e732237..768cff8 100644 --- a/src/packages/com_mokobackup/src/Engine/JpaUnarchiver.php +++ b/source/packages/com_mokojoombackup/src/Engine/JpaUnarchiver.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -19,7 +19,7 @@ * The RestoreEngine can then restore from the extracted files. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/source/packages/com_mokojoombackup/src/Engine/MokoRestore.php b/source/packages/com_mokojoombackup/src/Engine/MokoRestore.php new file mode 100644 index 0000000..4781699 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Engine/MokoRestore.php @@ -0,0 +1,1241 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Standalone restore/installer script generator. + * + * When "Include MokoRestore" is enabled on a profile, the backup archive + * is wrapped: + * + * outer.zip + * ├── restore.php ← Standalone installer (no Joomla needed) + * └── site-backup.zip ← The actual site backup + * + * Upload outer.zip to a blank server, extract, open restore.php in a + * browser, and it handles everything — self-contained site restoration + * with a Joomla-styled wizard interface. + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class MokoRestore +{ + /** + * Wrap a backup archive with the standalone restore script. + * + * @param string $backupArchive Path to the original backup ZIP + * @param string $outputPath Path for the wrapped archive + * + * @return string Path to the wrapped archive + */ + public static function wrap(string $backupArchive, string $outputPath): string + { + $zip = new \ZipArchive(); + + if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath); + } + + // Add the standalone restore script + $zip->addFromString('restore.php', self::generateRestoreScript()); + + // Add the original backup as a nested ZIP + $zip->addFile($backupArchive, 'site-backup.zip'); + + $zip->close(); + + return $outputPath; + } + + /** + * Generate the standalone restore.php script. + * + * This is a self-contained PHP file with a Joomla-styled wizard UI that: + * 1. Runs pre-installation checks (PHP, extensions, permissions) + * 2. Extracts site-backup.zip to the current directory + * 3. Imports database.sql using provided credentials + * 4. Creates or updates configuration.php + * 5. Resets super admin password (optional) + * 6. Runs client provisioning tasks (optional) + * 7. Cleans up restore artifacts + */ + private static function generateRestoreScript(): string + { + $php = self::generateBackend(); + $html = self::generateFrontend(); + + return $php . $html; + } + + /** + * Generate the PHP backend portion of the restore script. + */ + private static function generateBackend(): string + { + return <<<'PHP_BACKEND' + false, 'message' => 'Invalid security token. Reload the page.']); + exit; + } + + @set_time_limit(0); + @ini_set('max_execution_time', '0'); + @ini_set('memory_limit', '512M'); + @ignore_user_abort(true); + + try { + $result = handleAction($_POST['action'], $_POST); + echo json_encode($result); + } catch (Throwable $e) { + echo json_encode(['success' => false, 'message' => $e->getMessage()]); + } + + exit; +} + +function handleAction(string $action, array $data): array +{ + return match ($action) { + 'preflight' => actionPreflight(), + 'extract' => actionExtract($data), + 'testdb' => actionTestDb($data), + 'database' => actionDatabase($data), + 'config' => actionConfig($data), + 'listAdmins' => actionListAdmins($data), + 'resetAdmin' => actionResetAdmin($data), + 'provision' => actionProvision($data), + 'cleanup' => actionCleanup(), + default => ['success' => false, 'message' => 'Unknown action: ' . $action], + }; +} + +function actionPreflight(): array +{ + $checks = []; + + $checks[] = [ + 'label' => 'PHP Version', + 'value' => PHP_VERSION, + 'ok' => version_compare(PHP_VERSION, '8.1', '>='), + 'hint' => 'Joomla 4/5 requires PHP 8.1+', + ]; + + $checks[] = [ + 'label' => 'ZipArchive Extension', + 'value' => extension_loaded('zip') ? 'Available' : 'Missing', + 'ok' => extension_loaded('zip'), + 'hint' => 'Required to extract backup archives', + ]; + + $checks[] = [ + 'label' => 'PDO MySQL', + 'value' => extension_loaded('pdo_mysql') ? 'Available' : 'Missing', + 'ok' => extension_loaded('pdo_mysql'), + 'hint' => 'Required for database import', + ]; + + $checks[] = [ + 'label' => 'Multibyte String', + 'value' => extension_loaded('mbstring') ? 'Available' : 'Missing', + 'ok' => extension_loaded('mbstring'), + 'hint' => 'Required by Joomla for UTF-8 handling', + ]; + + $checks[] = [ + 'label' => 'JSON Extension', + 'value' => extension_loaded('json') ? 'Available' : 'Missing', + 'ok' => extension_loaded('json'), + 'hint' => 'Required by Joomla', + ]; + + $checks[] = [ + 'label' => 'Backup Archive', + 'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found', + 'ok' => file_exists(BACKUP_FILE), + 'hint' => 'site-backup.zip must be in the same directory as restore.php', + ]; + + $checks[] = [ + 'label' => 'Directory Writable', + 'value' => is_writable(RESTORE_DIR) ? 'Yes' : 'No', + 'ok' => is_writable(RESTORE_DIR), + 'hint' => 'The restore directory must be writable', + ]; + + $freeSpace = @disk_free_space(RESTORE_DIR); + $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0; + + $checks[] = [ + 'label' => 'Free Disk Space', + 'value' => $freeGB . ' GB', + 'ok' => $freeGB >= 0.5, + 'hint' => 'At least 500 MB free space recommended', + ]; + + $checks[] = [ + 'label' => 'Memory Limit', + 'value' => ini_get('memory_limit') ?: 'Unknown', + 'ok' => true, + 'hint' => 'Informational', + ]; + + $allOk = true; + + foreach ($checks as $c) { + if (!$c['ok']) { + $allOk = false; + } + } + + return ['success' => $allOk, 'checks' => $checks]; +} + +function actionExtract(array $data): array +{ + if (!file_exists(BACKUP_FILE)) { + throw new RuntimeException('Backup file not found: site-backup.zip'); + } + + $zip = new ZipArchive(); + + if ($zip->open(BACKUP_FILE) !== true) { + throw new RuntimeException('Cannot open backup archive'); + } + + $password = trim($data['archive_password'] ?? ''); + + if ($password !== '') { + $zip->setPassword($password); + } + + if (!$zip->extractTo(RESTORE_DIR)) { + $zip->close(); + throw new RuntimeException( + 'Extraction failed. ' . ($password !== '' ? 'Check the decryption password.' : 'The archive may be encrypted — provide a password.') + ); + } + + $count = $zip->numFiles; + $zip->close(); + + // Try to read existing configuration.php for pre-filling + $existingConfig = []; + $configFile = RESTORE_DIR . '/configuration.php'; + + if (is_file($configFile)) { + $content = file_get_contents($configFile); + + if (preg_match('/\$host\s*=\s*\'([^\']*)\'/', $content, $m)) { + $existingConfig['db_host'] = $m[1]; + } + + if (preg_match('/\$db\s*=\s*\'([^\']*)\'/', $content, $m)) { + $existingConfig['db_name'] = $m[1]; + } + + if (preg_match('/\$user\s*=\s*\'([^\']*)\'/', $content, $m)) { + $existingConfig['db_user'] = $m[1]; + } + + if (preg_match('/\$dbprefix\s*=\s*\'([^\']*)\'/', $content, $m)) { + $existingConfig['db_prefix'] = $m[1]; + } + + if (preg_match('/\$sitename\s*=\s*\'([^\']*)\'/', $content, $m)) { + $existingConfig['sitename'] = $m[1]; + } + } + + return [ + 'success' => true, + 'message' => "Extracted {$count} files", + 'config' => $existingConfig, + 'has_db' => is_file(RESTORE_DIR . '/database.sql'), + ]; +} + +function actionTestDb(array $data): array +{ + $host = $data['db_host'] ?? 'localhost'; + $name = $data['db_name'] ?? ''; + $user = $data['db_user'] ?? ''; + $pass = $data['db_pass'] ?? ''; + + if (empty($name) || empty($user)) { + throw new RuntimeException('Database name and user are required'); + } + + $pdo = new PDO( + "mysql:host={$host};dbname={$name};charset=utf8mb4", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5] + ); + + $version = $pdo->query('SELECT VERSION()')->fetchColumn(); + + return ['success' => true, 'message' => 'Connected — MySQL ' . $version]; +} + +function actionDatabase(array $data): array +{ + $host = $data['db_host'] ?? 'localhost'; + $name = $data['db_name'] ?? ''; + $user = $data['db_user'] ?? ''; + $pass = $data['db_pass'] ?? ''; + + if (empty($name) || empty($user)) { + throw new RuntimeException('Database name and user are required'); + } + + $sqlFile = RESTORE_DIR . '/database.sql'; + + if (!is_file($sqlFile)) { + return ['success' => true, 'message' => 'No database.sql found — skipped', 'statements' => 0, 'errors' => 0]; + } + + $pdo = new PDO( + "mysql:host={$host};dbname={$name};charset=utf8mb4", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + + $pdo->exec('SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"'); + $pdo->exec("SET time_zone = '+00:00'"); + $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); + + $sql = file_get_contents($sqlFile); + $parts = explode(";\n", $sql); + $statements = 0; + $errors = 0; + $errorList = []; + + foreach ($parts as $part) { + $part = trim($part); + + if ($part === '' || str_starts_with($part, '--') || str_starts_with($part, 'SET ')) { + continue; + } + + try { + $pdo->exec($part); + $statements++; + } catch (PDOException $e) { + $errors++; + + if (count($errorList) < 5) { + $errorList[] = substr($e->getMessage(), 0, 120); + } + } + } + + $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + + return [ + 'success' => ($statements > 0 || $errors === 0), + 'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''), + 'statements' => $statements, + 'errors' => $errors, + 'errorList' => $errorList, + ]; +} + +function actionConfig(array $data): array +{ + $host = $data['db_host'] ?? 'localhost'; + $dbName = $data['db_name'] ?? ''; + $dbUser = $data['db_user'] ?? ''; + $dbPass = $data['db_pass'] ?? ''; + $prefix = $data['db_prefix'] ?? 'moko_'; + $sitename = $data['sitename'] ?? 'Joomla Site'; + $livesite = $data['live_site'] ?? ''; + $tmpPath = RESTORE_DIR . '/tmp'; + $logPath = RESTORE_DIR . '/administrator/logs'; + + $configFile = RESTORE_DIR . '/configuration.php'; + + if (is_file($configFile)) { + // Update existing configuration.php + $config = file_get_contents($configFile); + + $replacements = [ + '/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$host}'", + '/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$dbName}'", + '/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$dbUser}'", + '/\$password\s*=\s*\'[^\']*\'/' => "\$password = '" . addcslashes($dbPass, "'\\") . "'", + '/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$prefix}'", + '/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'", + '/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'", + '/\$sitename\s*=\s*\'[^\']*\'/' => "\$sitename = '" . addcslashes($sitename, "'\\") . "'", + '/\$secret\s*=\s*\'[^\']*\'/' => "\$secret = '" . bin2hex(random_bytes(16)) . "'", + ]; + + if ($livesite !== '') { + $replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$livesite}'"; + } + + foreach ($replacements as $pattern => $replacement) { + $config = preg_replace($pattern, $replacement, $config); + } + + file_put_contents($configFile, $config); + + return ['success' => true, 'message' => 'configuration.php updated with new settings and fresh secret']; + } + + // Create new configuration.php from scratch + $secret = bin2hex(random_bytes(16)); + $newConfig = << true, 'message' => 'configuration.php created from scratch with fresh secret']; +} + +function actionListAdmins(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = $data['db_prefix'] ?? 'moko_'; + + // Find super admin users (group 8 = Super Users in Joomla) + $stmt = $pdo->prepare( + "SELECT u.id, u.name, u.username, u.email + FROM {$prefix}users u + INNER JOIN {$prefix}user_usergroup_map m ON m.user_id = u.id + WHERE m.group_id = 8 + ORDER BY u.id ASC" + ); + $stmt->execute(); + $admins = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($admins)) { + return ['success' => true, 'admins' => [], 'message' => 'No super admin users found']; + } + + return ['success' => true, 'admins' => $admins]; +} + +function actionResetAdmin(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = $data['db_prefix'] ?? 'moko_'; + $userId = (int) ($data['admin_id'] ?? 0); + $password = $data['new_password'] ?? ''; + + if ($userId < 1 || strlen($password) < 8) { + throw new RuntimeException('Select an admin and enter a password (8+ characters)'); + } + + $hash = password_hash($password, PASSWORD_DEFAULT); + + $stmt = $pdo->prepare("UPDATE {$prefix}users SET password = ?, requireReset = 0 WHERE id = ?"); + $stmt->execute([$hash, $userId]); + + if ($stmt->rowCount() === 0) { + throw new RuntimeException('User not found or password unchanged'); + } + + return ['success' => true, 'message' => 'Admin password updated successfully']; +} + +function actionProvision(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = $data['db_prefix'] ?? 'moko_'; + $tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; + $results = []; + + foreach ($tasks as $task) { + try { + switch ($task) { + case 'reset_hits': + $pdo->exec("UPDATE {$prefix}content SET hits = 0"); + $results[] = 'Content hits reset to 0'; + break; + + case 'clear_sessions': + $pdo->exec("TRUNCATE TABLE {$prefix}session"); + $results[] = 'Sessions cleared'; + break; + + case 'clear_cache': + // Clear Joomla cache tables + foreach (['cache', 'cache_extension'] as $tbl) { + try { + $pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}"); + } catch (PDOException $e) { + // Table may not exist + } + } + $results[] = 'Cache tables cleared'; + break; + + case 'clear_update_keys': + $pdo->exec("UPDATE {$prefix}update_sites SET extra_query = ''"); + $results[] = 'Update site download keys cleared'; + break; + + case 'clear_updates': + $pdo->exec("DELETE FROM {$prefix}updates"); + $results[] = 'Pending updates cleared'; + break; + + case 'clear_api_tokens': + try { + $pdo->exec("TRUNCATE TABLE {$prefix}user_keys"); + $results[] = 'API tokens cleared'; + } catch (PDOException $e) { + $results[] = 'API tokens: table not found (skipped)'; + } + break; + + case 'clear_action_logs': + try { + $pdo->exec("TRUNCATE TABLE {$prefix}action_logs"); + $results[] = 'Action logs cleared'; + } catch (PDOException $e) { + $results[] = 'Action logs: table not found (skipped)'; + } + break; + + case 'clear_mail_queue': + try { + $pdo->exec("TRUNCATE TABLE {$prefix}mail_queue"); + $results[] = 'Mail queue cleared'; + } catch (PDOException $e) { + $results[] = 'Mail queue: table not found (skipped)'; + } + break; + + default: + $results[] = "Unknown task: {$task}"; + } + } catch (Throwable $e) { + $results[] = "Error ({$task}): " . $e->getMessage(); + } + } + + return ['success' => true, 'results' => $results, 'message' => count($results) . ' provisioning tasks completed']; +} + +function actionCleanup(): array +{ + $removed = []; + + foreach (['database.sql', 'site-backup.zip'] as $file) { + $path = RESTORE_DIR . '/' . $file; + + if (is_file($path) && @unlink($path)) { + $removed[] = $file; + } + } + + return [ + 'success' => true, + 'message' => 'Removed: ' . (empty($removed) ? '(none)' : implode(', ', $removed)) + . '. IMPORTANT: Delete restore.php manually!', + ]; +} + +function getDbConnection(array $data): PDO +{ + $host = $data['db_host'] ?? 'localhost'; +// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); } + $name = $data['db_name'] ?? ''; +// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); } + $user = $data['db_user'] ?? ''; +// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); } + $pass = $data['db_pass'] ?? ''; +// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); } + + // Validate db_prefix to prevent SQL injection + $prefix = $data['db_prefix'] ?? 'moko_'; +// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); } + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$\/', $prefix)) { + throw new RuntimeException('Invalid table prefix format'); + } + + return new PDO( + "mysql:host={$host};dbname={$name};charset=utf8mb4", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); +} + +// ── Render HTML UI ────────────────────────────────────────────────── +?> +PHP_BACKEND; + } + + /** + * Generate the HTML/CSS/JS frontend of the restore script. + */ + private static function generateFrontend(): string + { + return <<<'HTML_FRONTEND' + + + + + +MokoRestore — Site Installer + + + +
+

MokoRestore

+

Standalone Site Installer — MokoJoomBackup

+
+ +
+
+ Security: Delete restore.php immediately after installation is complete. +
+ + +
+
1Checks
+
2Extract
+
3Database
+
4Configuration
+
5Admin
+
6Provisioning
+
7Complete
+
+ + +
+

Pre-Installation Checks

+

Verify your server meets the requirements for Joomla and MokoRestore.

+
    +
    + + +
    +
    + + +
    +

    Extract Backup

    +

    Extract site-backup.zip into the current directory.

    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +

    Database Configuration

    +

    Enter the database credentials for this server. The SQL dump will be imported.

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +

    Site Configuration

    +

    Update or create configuration.php with the correct settings for this server.

    +
    +
    + + +
    Leave blank to auto-detect. Set this if using a reverse proxy or custom domain.
    +
    +
    + A new Joomla secret will be generated automatically for security. +
    +
    +
    + + +
    +
    + + +
    +

    Super Admin Password

    +

    Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.

    +
    + + +
    +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +

    Client Provisioning

    +

    Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.

    +
      +
    • Reset content hitsSet all article hit counters to 0
    • +
    • Clear sessionsRemove all active user sessions
    • +
    • Clear cacheTruncate Joomla cache tables
    • +
    • Clear download keysRemove update site extra_query keys
    • +
    • Clear pending updatesRemove cached update records
    • +
    • Clear API tokensRemove all personal access tokens
    • +
    • Clear action logsRemove admin action log history
    • +
    • Clear mail queueRemove pending outbound emails
    • +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +

    Installation Complete

    +

    Your Joomla site has been restored and configured.

    +
    + Success! The site restoration is complete. +
    +
    + Important: Delete restore.php and site-backup.zip from your server immediately for security. +
    +
    + + Open Joomla Admin + View Site +
    +
    +
    + + +
    +
    + +
    +
    + + + + + + +HTML_FRONTEND; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/NotificationSender.php b/source/packages/com_mokojoombackup/src/Engine/NotificationSender.php similarity index 66% rename from src/packages/com_mokobackup/src/Engine/NotificationSender.php rename to source/packages/com_mokojoombackup/src/Engine/NotificationSender.php index 82808a0..fc2d96a 100644 --- a/src/packages/com_mokobackup/src/Engine/NotificationSender.php +++ b/source/packages/com_mokojoombackup/src/Engine/NotificationSender.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -11,7 +11,7 @@ * Uses Joomla's built-in mail system (Factory::getMailer()). */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -33,9 +33,13 @@ class NotificationSender */ public static function send(object $profile, object $record, bool $success, string $logText = ''): bool { - $notifyEmail = trim($profile->notify_email ?? ''); + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; - if (empty($notifyEmail)) { + // Resolve user group members to email addresses + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { return false; } @@ -54,9 +58,10 @@ class NotificationSender $siteName = $config->get('sitename', 'Joomla Site'); $siteUrl = Uri::root(); - // Parse recipient list (comma-separated) + // Parse recipient list (comma-separated) + user group emails $recipients = array_map('trim', explode(',', $notifyEmail)); - $recipients = array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); if (empty($recipients)) { return false; @@ -68,7 +73,7 @@ class NotificationSender // Build subject $statusLabel = $success ? 'SUCCESS' : 'FAILED'; - $mailer->setSubject("[MokoBackup] {$statusLabel}: {$record->description} — {$siteName}"); + $mailer->setSubject("[MokoJoomBackup] {$statusLabel}: {$record->description} — {$siteName}"); // Build body $duration = ''; @@ -128,9 +133,48 @@ class NotificationSender return $mailer->Send(); } catch (\Throwable $e) { // Don't let notification failure break the backup flow - error_log('MokoBackup notification error: ' . $e->getMessage()); + error_log('MokoJoomBackup notification error: ' . $e->getMessage()); return false; } } + + /** + * Resolve user group IDs to email addresses of group members. + * + * @param string|array $groups Comma-separated group IDs or array + * + * @return array Email addresses + */ + private static function resolveUserGroupEmails(string|array $groups): array + { + if (empty($groups)) { + return []; + } + + if (\is_string($groups)) { + $groups = array_filter(array_map('intval', explode(',', $groups))); + } + + if (empty($groups)) { + return []; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('u.email')) + ->from($db->quoteName('#__users', 'u')) + ->join('INNER', $db->quoteName('#__user_usergroup_map', 'ugm') . ' ON ugm.user_id = u.id') + ->where($db->quoteName('u.block') . ' = 0') + ->whereIn($db->quoteName('ugm.group_id'), $groups); + $db->setQuery($query); + + return $db->loadColumn() ?: []; + } catch (\Throwable $e) { + error_log('MokoJoomBackup: Could not resolve user group emails: ' . $e->getMessage()); + + return []; + } + } } diff --git a/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php b/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php new file mode 100644 index 0000000..cbac2c9 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Engine/PlaceholderResolver.php @@ -0,0 +1,122 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Resolves placeholders like [host], [date], [profile_name] in backup + * directory paths and archive filename formats. + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class PlaceholderResolver +{ + /** + * Supported placeholders and their descriptions (for documentation). + */ + public const PLACEHOLDERS = [ + '[host]' => 'Server hostname', + '[date]' => 'Date as Ymd (e.g. 20260604)', + '[time]' => 'Time as His (e.g. 143025)', + '[datetime]' => 'Date and time as Ymd_His', + '[year]' => 'Four-digit year', + '[month]' => 'Two-digit month', + '[day]' => 'Two-digit day', + '[hour]' => 'Two-digit hour (24h)', + '[minute]' => 'Two-digit minute', + '[second]' => 'Two-digit second', + '[profile_id]' => 'Backup profile ID', + '[profile_name]' => 'Profile title (sanitized)', + '[site_name]' => 'Joomla site name (sanitized)', + '[type]' => 'Backup type (full, database, files, differential)', + '[random]' => 'Random 6-character hex string', + ]; + + private array $replacements; + + /** + * @param object $profile The backup profile object + */ + public function __construct(object $profile) + { + $now = new \DateTimeImmutable('now'); + $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); + + $siteName = ''; + + try { + $siteName = Factory::getApplication()->get('sitename', ''); + } catch (\Throwable $e) { + // Fallback: not critical + } + + $this->replacements = [ + '[host]' => $hostname, + '[date]' => $now->format('Ymd'), + '[time]' => $now->format('His'), + '[datetime]' => $now->format('Ymd_His'), + '[year]' => $now->format('Y'), + '[month]' => $now->format('m'), + '[day]' => $now->format('d'), + '[hour]' => $now->format('H'), + '[minute]' => $now->format('i'), + '[second]' => $now->format('s'), + '[profile_id]' => (string) ($profile->id ?? '0'), + '[profile_name]' => $this->sanitize($profile->title ?? 'default'), + '[site_name]' => $this->sanitize($siteName ?: 'joomla'), + '[type]' => $profile->backup_type ?? 'full', + '[random]' => bin2hex(random_bytes(3)), + ]; + } + + /** + * Replace all placeholders in a string. + * + * @param string $template String containing [placeholder] tokens + * + * @return string Resolved string + */ + public function resolve(string $template): string + { + return str_replace( + array_keys($this->replacements), + array_values($this->replacements), + $template + ); + } + + /** + * Get the raw hostname value (for backward compatibility). + */ + public function getHostname(): string + { + return $this->replacements['[host]']; + } + + /** + * Get the datetime tag value (for backward compatibility). + */ + public function getTag(): string + { + return $this->replacements['[datetime]']; + } + + /** + * Sanitize a string for use in filenames/paths. + * Keeps alphanumerics, dots, hyphens, underscores. Replaces spaces with hyphens. + */ + private function sanitize(string $value): string + { + $value = str_replace(' ', '-', trim($value)); + + return preg_replace('/[^a-zA-Z0-9._-]/', '', $value); + } +} diff --git a/src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php b/source/packages/com_mokojoombackup/src/Engine/RemoteUploaderInterface.php similarity index 89% rename from src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php rename to source/packages/com_mokojoombackup/src/Engine/RemoteUploaderInterface.php index 67c1808..d2f1dd7 100644 --- a/src/packages/com_mokobackup/src/Engine/RemoteUploaderInterface.php +++ b/source/packages/com_mokojoombackup/src/Engine/RemoteUploaderInterface.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/source/packages/com_mokojoombackup/src/Engine/RestoreEngine.php similarity index 90% rename from src/packages/com_mokobackup/src/Engine/RestoreEngine.php rename to source/packages/com_mokojoombackup/src/Engine/RestoreEngine.php index eb33467..64115c9 100644 --- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokojoombackup/src/Engine/RestoreEngine.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -18,7 +18,7 @@ * 6. Clean up staging directory */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -57,7 +57,7 @@ class RestoreEngine // Load backup record $query = $db->getQuery(true) ->select('*') - ->from($db->quoteName('#__mokobackup_records')) + ->from($db->quoteName('#__mokojoombackup_records')) ->where($db->quoteName('id') . ' = ' . $recordId); $db->setQuery($query); $record = $db->loadObject(); @@ -77,7 +77,7 @@ class RestoreEngine } // Create staging directory - $this->stagingDir = JPATH_ROOT . '/tmp/mokobackup-restore-' . $record->tag; + $this->stagingDir = JPATH_ROOT . '/tmp/mokojoombackup-restore-' . $record->tag; if (is_dir($this->stagingDir)) { $this->recursiveDelete($this->stagingDir); @@ -89,12 +89,15 @@ class RestoreEngine // Step 1: Extract archive to staging $this->log('Extracting archive: ' . basename($archivePath)); - // Detect format: JPA or ZIP + // Detect format: JPA, tar.gz, or ZIP if (JpaUnarchiver::isJpaFile($archivePath)) { $this->log('Detected JPA format (Akeeba Backup archive)'); $jpa = new JpaUnarchiver($archivePath, $this->stagingDir); $count = $jpa->extract(); $this->log('Extracted ' . $count . ' files from JPA'); + } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { + $this->log('Detected tar.gz format'); + $this->extractTarGz($archivePath); } else { $this->extractArchive($archivePath, $password); } @@ -200,6 +203,16 @@ class RestoreEngine $zip->close(); } + /** + * Extract a tar.gz archive to the staging directory. + */ + private function extractTarGz(string $archivePath): void + { + $phar = new \PharData($archivePath); + $phar->extractTo($this->stagingDir, null, true); + $this->log('Extracted tar.gz archive'); + } + /** * Recursively delete a directory and all its contents. */ diff --git a/src/packages/com_mokobackup/src/Engine/S3Uploader.php b/source/packages/com_mokojoombackup/src/Engine/S3Uploader.php similarity index 99% rename from src/packages/com_mokobackup/src/Engine/S3Uploader.php rename to source/packages/com_mokojoombackup/src/Engine/S3Uploader.php index eeae5b4..0c489de 100644 --- a/src/packages/com_mokobackup/src/Engine/S3Uploader.php +++ b/source/packages/com_mokojoombackup/src/Engine/S3Uploader.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -12,7 +12,7 @@ * No SDK dependency — pure PHP with cURL. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; diff --git a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php similarity index 84% rename from src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php rename to source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php index 02ef27a..e54b1b6 100644 --- a/src/packages/com_mokobackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokojoombackup/src/Engine/SteppedBackupEngine.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -16,7 +16,7 @@ * where ini_set() and set_time_limit() are disabled. */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -36,7 +36,7 @@ class SteppedBackupEngine // Load profile $query = $db->getQuery(true) ->select('*') - ->from($db->quoteName('#__mokobackup_profiles')) + ->from($db->quoteName('#__mokojoombackup_profiles')) ->where($db->quoteName('id') . ' = ' . $profileId); $db->setQuery($query); $profile = $db->loadObject(); @@ -55,22 +55,25 @@ class SteppedBackupEngine $session->excludeDirs = $this->parseNewlineList($profile->exclude_dirs ?? ''); $session->excludeFiles = $this->parseNewlineList($profile->exclude_files ?? ''); $session->excludeTables = $this->parseNewlineList($profile->exclude_tables ?? ''); - $session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokobackup/backups'; + $session->backupDir = $profile->backup_dir ?: 'administrator/components/com_mokojoombackup/backups'; $session->remoteStorage = $profile->remote_storage ?? 'none'; - $session->includeKickstart = (bool) ($profile->include_kickstart ?? false); + $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true); - // Build archive path - $backupDir = JPATH_ROOT . '/' . $session->backupDir; + // Resolve placeholders in directory and filename + $resolver = new PlaceholderResolver($profile); + $backupDir = $this->resolveBackupDir($resolver->resolve($session->backupDir)); if (!is_dir($backupDir)) { - mkdir($backupDir, 0755, true); + if (!mkdir($backupDir, 0755, true)) { + return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir]; + } } - $now = date('Y-m-d H:i:s'); - $tag = date('Ymd_His'); - $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); - $archiveName = $hostname . '_' . $tag . '_profile' . $profileId . '.zip'; + $now = date('Y-m-d H:i:s'); + $tag = $resolver->getTag(); + $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $archiveName = $resolver->resolve($nameFormat) . '.zip'; $session->archivePath = $backupDir . '/' . $archiveName; $session->archiveName = $archiveName; @@ -98,7 +101,7 @@ class SteppedBackupEngine 'log' => '', ]; - $db->insertObject('#__mokobackup_records', $record, 'id'); + $db->insertObject('#__mokojoombackup_records', $record, 'id'); $session->recordId = $record->id; // Determine what work needs to be done and estimate steps @@ -231,11 +234,15 @@ class SteppedBackupEngine . "-- Prefix: " . $db->getPrefix() . "\n\n" . "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n" . "SET time_zone = \"+00:00\";\n\n"; - file_put_contents($sqlFile, $header); + if (file_put_contents($sqlFile, $header) === false) { + throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile); + } $flags = FILE_APPEND; } - file_put_contents($sqlFile, $sql, $flags); + if (file_put_contents($sqlFile, $sql, $flags) === false) { + throw new \RuntimeException('Cannot write SQL dump: ' . $sqlFile); + } $session->dbSize += strlen($sql); $session->tableIndex++; @@ -288,7 +295,7 @@ class SteppedBackupEngine } /** - * Finalize phase: add database.sql to ZIP, apply kickstart wrapper. + * Finalize phase: add database.sql to ZIP, apply MokoRestore wrapper. */ private function stepFinalize(SteppedSession $session): void { @@ -314,15 +321,15 @@ class SteppedBackupEngine $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; - // Kickstart wrapper - if ($session->includeKickstart) { - $session->log('Wrapping with Kickstart restore script...'); - $kickstartPath = $session->archivePath . '.kickstart.zip'; - Kickstart::wrap($session->archivePath, $kickstartPath); + // MokoRestore wrapper + if ($session->includeMokoRestore) { + $session->log('Wrapping with MokoRestore script...'); + $mokoRestorePath = $session->archivePath . '.mokorestore.zip'; + MokoRestore::wrap($session->archivePath, $mokoRestorePath); @unlink($session->archivePath); - rename($kickstartPath, $session->archivePath); + rename($mokoRestorePath, $session->archivePath); $totalSize = filesize($session->archivePath); - $session->log('Kickstart archive created'); + $session->log('MokoRestore archive created'); } // Update record @@ -338,7 +345,7 @@ class SteppedBackupEngine 'filesexist' => 1, ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); $session->currentStep++; $session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete'; @@ -360,7 +367,7 @@ class SteppedBackupEngine // Reload profile for remote settings $query = $db->getQuery(true) ->select('*') - ->from($db->quoteName('#__mokobackup_profiles')) + ->from($db->quoteName('#__mokojoombackup_profiles')) ->where($db->quoteName('id') . ' = ' . $session->profileId); $db->setQuery($query); $profile = $db->loadObject(); @@ -368,6 +375,7 @@ class SteppedBackupEngine $uploader = match ($session->remoteStorage) { 'ftp' => new FtpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), + 's3' => new S3Uploader($profile), default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage), }; @@ -395,7 +403,7 @@ class SteppedBackupEngine 'filesexist' => is_file($session->archivePath) ? 1 : 0, ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); $session->currentStep++; $session->phase = 'complete'; @@ -408,15 +416,23 @@ class SteppedBackupEngine */ private function completeRecord(SteppedSession $session): void { - $db = Factory::getDbo(); + $db = Factory::getDbo(); + $logContent = implode("\n", $session->log); + + // Write log file alongside the archive + $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $session->archivePath); + if (@file_put_contents($logPath, $logContent) === false) { + error_log('MokoJoomBackup: Could not write log file: ' . $logPath); + } + $update = (object) [ 'id' => $session->recordId, 'status' => 'complete', 'backupend' => date('Y-m-d H:i:s'), - 'log' => implode("\n", $session->log), + 'log' => $logContent, ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); } /** @@ -432,7 +448,7 @@ class SteppedBackupEngine 'log' => implode("\n", $session->log), ]; - $db->updateObject('#__mokobackup_records', $update, 'id'); + $db->updateObject('#__mokojoombackup_records', $update, 'id'); } /** @@ -536,6 +552,19 @@ class SteppedBackupEngine return $tables; } + /** + * Resolve a backup directory path. Absolute paths are used as-is, + * relative paths are resolved from JPATH_ROOT. + */ + private function resolveBackupDir(string $dir): string + { + if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) { + return rtrim($dir, '/\\'); + } + + return JPATH_ROOT . '/' . $dir; + } + private function parseNewlineList(string $text): array { if (empty($text)) { diff --git a/src/packages/com_mokobackup/src/Engine/SteppedSession.php b/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php similarity index 88% rename from src/packages/com_mokobackup/src/Engine/SteppedSession.php rename to source/packages/com_mokojoombackup/src/Engine/SteppedSession.php index b4f3002..7f83d80 100644 --- a/src/packages/com_mokobackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokojoombackup/src/Engine/SteppedSession.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -17,7 +17,7 @@ * Phases: init → database → files → finalize → upload → complete */ -namespace Joomla\Component\MokoBackup\Administrator\Engine; +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; defined('_JEXEC') or die; @@ -51,7 +51,7 @@ class SteppedSession public array $excludeFiles = []; public array $excludeTables = []; public string $remoteStorage = 'none'; - public bool $includeKickstart = false; + public bool $includeMokoRestore = false; public bool $remoteKeepLocal = true; // Progress @@ -62,10 +62,12 @@ class SteppedSession private static function getSessionDir(): string { - $dir = JPATH_ROOT . '/tmp/mokobackup-sessions'; + $dir = JPATH_ROOT . '/tmp/mokojoombackup-sessions'; if (!is_dir($dir)) { - mkdir($dir, 0755, true); + if (!mkdir($dir, 0755, true)) { + throw new \RuntimeException('Cannot create session directory: ' . $dir); + } } return $dir; @@ -124,7 +126,9 @@ class SteppedSession public function save(): void { $path = self::getSessionPath($this->sessionId); - file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT)); + if (file_put_contents($path, json_encode(get_object_vars($this), JSON_PRETTY_PRINT)) === false) { + throw new \RuntimeException('Cannot save backup session: ' . $path); + } } /** diff --git a/source/packages/com_mokojoombackup/src/Engine/TarGzArchiver.php b/source/packages/com_mokojoombackup/src/Engine/TarGzArchiver.php new file mode 100644 index 0000000..6e8e629 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Engine/TarGzArchiver.php @@ -0,0 +1,63 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class TarGzArchiver implements ArchiverInterface +{ + private \PharData $tar; + private string $tarPath; + + public function open(string $path): void + { + // PharData creates .tar first, then we compress to .tar.gz + // Strip .gz to get the .tar path for initial creation + $this->tarPath = preg_replace('/\.gz$/', '', $path); + + // Remove existing files to avoid "already exists" errors + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + + if (is_file($path)) { + @unlink($path); + } + + $this->tar = new \PharData($this->tarPath); + } + + public function addFromString(string $localName, string $contents): void + { + $this->tar->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->tar->addFile($filePath, $localName); + } + + public function close(): void + { + // Compress the .tar to .tar.gz + $this->tar->compress(\Phar::GZ); + + // Remove the uncompressed .tar + if (is_file($this->tarPath)) { + @unlink($this->tarPath); + } + } + + public function getExtension(): string + { + return 'tar.gz'; + } +} diff --git a/source/packages/com_mokojoombackup/src/Engine/ZipArchiver.php b/source/packages/com_mokojoombackup/src/Engine/ZipArchiver.php new file mode 100644 index 0000000..1b54551 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Engine/ZipArchiver.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\MokoJoomBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +class ZipArchiver implements ArchiverInterface +{ + private \ZipArchive $zip; + + public function open(string $path): void + { + $this->zip = new \ZipArchive(); + + if ($this->zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create ZIP archive: ' . $path); + } + } + + public function addFromString(string $localName, string $contents): void + { + $this->zip->addFromString($localName, $contents); + } + + public function addFile(string $filePath, string $localName): void + { + $this->zip->addFile($filePath, $localName); + } + + public function close(): void + { + $this->zip->close(); + } + + public function getExtension(): string + { + return 'zip'; + } +} diff --git a/src/packages/com_mokobackup/src/Engine/index.html b/source/packages/com_mokojoombackup/src/Engine/index.html similarity index 100% rename from src/packages/com_mokobackup/src/Engine/index.html rename to source/packages/com_mokojoombackup/src/Engine/index.html diff --git a/src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php b/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php similarity index 68% rename from src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php rename to source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php index a7a6ed9..f5dc0d5 100644 --- a/src/packages/com_mokobackup/src/Extension/MokoBackupComponent.php +++ b/source/packages/com_mokojoombackup/src/Extension/MokoJoomBackupComponent.php @@ -2,18 +2,18 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Extension; defined('_JEXEC') or die; use Joomla\CMS\Extension\MVCComponent; -class MokoBackupComponent extends MVCComponent +class MokoJoomBackupComponent extends MVCComponent { } diff --git a/src/packages/com_mokobackup/src/Extension/index.html b/source/packages/com_mokojoombackup/src/Extension/index.html similarity index 100% rename from src/packages/com_mokobackup/src/Extension/index.html rename to source/packages/com_mokojoombackup/src/Extension/index.html diff --git a/source/packages/com_mokojoombackup/src/Field/DatabaseTablesField.php b/source/packages/com_mokojoombackup/src/Field/DatabaseTablesField.php new file mode 100644 index 0000000..4da3578 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Field/DatabaseTablesField.php @@ -0,0 +1,147 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class DatabaseTablesField extends FormField +{ + protected $type = 'DatabaseTables'; + + protected function getInput(): string + { + $db = Factory::getDbo(); + $tables = $db->getTableList(); + $prefix = $db->getPrefix(); + + // Parse current exclusions (newline-separated, with optional :data-only suffix) + $excludeData = []; + $excludeStructure = []; + + if (!empty($this->value)) { + $lines = array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value)))); + + foreach ($lines as $line) { + // Normalize table name to real prefix for comparison + if (str_ends_with($line, ':data-only')) { + $tableName = str_replace('#__', $prefix, substr($line, 0, -10)); + $excludeData[$tableName] = true; + } elseif (str_ends_with($line, ':structure-only')) { + $tableName = str_replace('#__', $prefix, substr($line, 0, -15)); + $excludeStructure[$tableName] = true; + } else { + // No suffix = exclude both (backward compatible) + $tableName = str_replace('#__', $prefix, $line); + $excludeData[$tableName] = true; + $excludeStructure[$tableName] = true; + } + } + } + + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + + $html = '
    '; + $html .= ''; + $html .= '
    ' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '
    '; + $html .= '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($tables as $table) { + $dataChecked = isset($excludeData[$table]) ? ' checked' : ''; + $structureChecked = isset($excludeStructure[$table]) ? ' checked' : ''; + + // Convert to #__ notation for storage + $storeValue = $table; + + if (str_starts_with($table, $prefix)) { + $storeValue = '#__' . substr($table, \strlen($prefix)); + } + + $safeValue = htmlspecialchars($storeValue, ENT_QUOTES, 'UTF-8'); + $safeTable = htmlspecialchars($table, ENT_QUOTES, 'UTF-8'); + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
    ' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA') . '' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE') . '' . Text::_('COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME') . '
    ' . $safeTable . '
    '; + + // Script to sync checkboxes to hidden field + $html .= << +SCRIPT; + + return $html; + } +} diff --git a/source/packages/com_mokojoombackup/src/Field/DirectoryFilterField.php b/source/packages/com_mokojoombackup/src/Field/DirectoryFilterField.php new file mode 100644 index 0000000..ac9b663 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Field/DirectoryFilterField.php @@ -0,0 +1,259 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Interactive directory tree field with checkboxes for exclude/include filtering. + * Loads the directory tree from the server via AJAX (browseDir endpoint). + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class DirectoryFilterField extends FormField +{ + protected $type = 'DirectoryFilter'; + + protected function getInput(): string + { + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $mode = htmlspecialchars((string) ($this->element['mode'] ?? 'exclude'), ENT_QUOTES, 'UTF-8'); + + // Parse current values (newline-separated) + $items = []; + + if (!empty($this->value)) { + $items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))))); + } + + $itemsJson = json_encode($items); + $jRoot = json_encode(JPATH_ROOT); + + $labelExclude = Text::_('COM_MOKOJOOMBACKUP_FILTER_EXCLUDED'); + $labelInclude = Text::_('COM_MOKOJOOMBACKUP_FILTER_INCLUDED'); + $labelManual = Text::_('COM_MOKOJOOMBACKUP_FILTER_ADD_MANUAL'); + $addLabel = Text::_('JGLOBAL_FIELD_ADD'); + $placeholder = htmlspecialchars((string) ($this->element['hint'] ?? 'path/to/directory'), ENT_QUOTES, 'UTF-8'); + + return << + + + +
    + + +
    + + +
    + + +
    +
    + + +
    +
    +
    + + + + + +HTML; + } +} diff --git a/source/packages/com_mokojoombackup/src/Field/ExcludeListField.php b/source/packages/com_mokojoombackup/src/Field/ExcludeListField.php new file mode 100644 index 0000000..a170671 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Field/ExcludeListField.php @@ -0,0 +1,120 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class ExcludeListField extends FormField +{ + protected $type = 'ExcludeList'; + + protected function getInput(): string + { + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $placeholder = htmlspecialchars((string) ($this->element['hint'] ?? ''), ENT_QUOTES, 'UTF-8'); + + // Parse current values (newline-separated) + $items = []; + + if (!empty($this->value)) { + $items = array_values(array_filter(array_map('trim', explode("\n", str_replace("\r", '', $this->value))))); + } + + $html = '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($items as $item) { + $safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8'); + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
    '; + $html .= ''; + $html .= '
    '; + + $html .= << +SCRIPT; + + return $html; + } +} diff --git a/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php b/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php new file mode 100644 index 0000000..f0ac4d2 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Field/FolderPickerField.php @@ -0,0 +1,239 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Field; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Form\FormField; +use Joomla\CMS\Language\Text; + +class FolderPickerField extends FormField +{ + protected $type = 'FolderPicker'; + + protected function getInput(): string + { + $value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8'); + $id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8'); + $name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8'); + $jRoot = JPATH_ROOT; + + // Resolve to absolute for display + $rawValue = $this->value ?: $this->default; + + if ($rawValue && $rawValue[0] !== '/') { + $absPath = $jRoot . '/' . $rawValue; + } else { + $absPath = $rawValue; + } + + // Build placeholder map for JS resolution + $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); + $siteName = ''; + + try { + $siteName = Factory::getApplication()->get('sitename', ''); + } catch (\Throwable $e) { + // fallback + } + + $sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName))); + + $placeholders = [ + '[host]' => $hostname, + '[site_name]' => $sanitizedSiteName ?: 'joomla', + '[profile_id]' => '1', + '[profile_name]' => 'default', + '[type]' => 'full', + '[year]' => date('Y'), + '[month]' => date('m'), + '[day]' => date('d'), + '[date]' => date('Ymd'), + ]; + + $placeholdersJson = json_encode($placeholders); + + // Resolve placeholders for the status display + $resolvedPath = str_replace(array_keys($placeholders), array_values($placeholders), $absPath); + $hasPlaceholders = preg_match('/\[.+\]/', $absPath); + + if ($hasPlaceholders) { + $exists = is_dir($resolvedPath); + $statusClass = $exists ? 'text-success' : 'text-info'; + $statusIcon = $exists ? 'icon-publish' : 'icon-info-circle'; + $statusText = Text::_('COM_MOKOJOOMBACKUP_FOLDER_PLACEHOLDER'); + $resolvedSafe = htmlspecialchars($resolvedPath, ENT_QUOTES, 'UTF-8'); + $statusDetail = "{$statusText}: {$resolvedSafe}"; + } else { + $exists = is_dir($absPath); + $statusClass = $exists ? 'text-success' : 'text-danger'; + $statusIcon = $exists ? 'icon-publish' : 'icon-unpublish'; + $statusText = $exists + ? Text::_('COM_MOKOJOOMBACKUP_FOLDER_EXISTS') + : Text::_('COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND'); + $absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8'); + $statusDetail = "{$statusText}: {$absPathSafe}"; + } + + return << + + + +
    + + + {$statusDetail} + +
    + + +HTML; + } +} diff --git a/src/packages/com_mokobackup/src/Model/BackupModel.php b/source/packages/com_mokojoombackup/src/Model/BackupModel.php similarity index 78% rename from src/packages/com_mokobackup/src/Model/BackupModel.php rename to source/packages/com_mokojoombackup/src/Model/BackupModel.php index 3379baa..fd3bc73 100644 --- a/src/packages/com_mokobackup/src/Model/BackupModel.php +++ b/source/packages/com_mokojoombackup/src/Model/BackupModel.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Model; defined('_JEXEC') or die; @@ -20,7 +20,7 @@ class BackupModel extends AdminModel public function getForm($data = [], $loadData = true) { $form = $this->loadForm( - 'com_mokobackup.backup', + 'com_mokojoombackup.backup', 'backup', ['control' => 'jform', 'load_data' => $loadData] ); @@ -30,7 +30,7 @@ class BackupModel extends AdminModel protected function loadFormData(): object { - $data = Factory::getApplication()->getUserState('com_mokobackup.edit.backup.data', []); + $data = Factory::getApplication()->getUserState('com_mokojoombackup.edit.backup.data', []); if (empty($data)) { $data = $this->getItem(); diff --git a/src/packages/com_mokobackup/src/Model/BackupsModel.php b/source/packages/com_mokojoombackup/src/Model/BackupsModel.php similarity index 88% rename from src/packages/com_mokobackup/src/Model/BackupsModel.php rename to source/packages/com_mokojoombackup/src/Model/BackupsModel.php index 7b4d977..c1d6a19 100644 --- a/src/packages/com_mokobackup/src/Model/BackupsModel.php +++ b/source/packages/com_mokojoombackup/src/Model/BackupsModel.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Model; defined('_JEXEC') or die; @@ -41,11 +41,11 @@ class BackupsModel extends ListModel $query = $db->getQuery(true); $query->select('a.*') - ->from($db->quoteName('#__mokobackup_records', 'a')); + ->from($db->quoteName('#__mokojoombackup_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'); + ->join('LEFT', $db->quoteName('#__mokojoombackup_profiles', 'p') . ' ON p.id = a.profile_id'); // Filter by status $status = $this->getState('filter.status'); diff --git a/source/packages/com_mokojoombackup/src/Model/DashboardModel.php b/source/packages/com_mokojoombackup/src/Model/DashboardModel.php new file mode 100644 index 0000000..cc8045f --- /dev/null +++ b/source/packages/com_mokojoombackup/src/Model/DashboardModel.php @@ -0,0 +1,216 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\Model; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Model\BaseDatabaseModel; + +class DashboardModel extends BaseDatabaseModel +{ + /** + * Get the most recent completed backup record. + * + * @return object|null + */ + public function getLastBackup(): ?object + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select('r.*, p.title AS profile_title') + ->from($db->quoteName('#__mokojoombackup_records', 'r')) + ->join('LEFT', $db->quoteName('#__mokojoombackup_profiles', 'p') . ' ON p.id = r.profile_id') + ->where($db->quoteName('r.status') . ' = ' . $db->quote('complete')) + ->order($db->quoteName('r.backupend') . ' DESC'); + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: null; + } + + /** + * Query com_scheduler for the next scheduled MokoJoomBackup task. + * + * @return object|null Object with next_execution and title, or null + */ + public function getNextScheduled(): ?object + { + $db = $this->getDatabase(); + + try { + $query = $db->getQuery(true) + ->select($db->quoteName(['t.next_execution', 't.title'])) + ->from($db->quoteName('#__scheduler_tasks', 't')) + ->where($db->quoteName('t.type') . ' = ' . $db->quote('mokojoombackup.run_profile')) + ->where($db->quoteName('t.state') . ' = 1') + ->order($db->quoteName('t.next_execution') . ' ASC'); + $db->setQuery($query, 0, 1); + + return $db->loadObject() ?: null; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Get backup statistics. + * + * @return object Object with total_count, total_size, fail_count_7d + */ + public function getStats(): object + { + $db = $this->getDatabase(); + + // Total completed backups and storage + $query = $db->getQuery(true) + ->select('COUNT(*) AS total_count') + ->select('COALESCE(SUM(' . $db->quoteName('total_size') . '), 0) AS total_size') + ->from($db->quoteName('#__mokojoombackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $stats = $db->loadObject(); + + // Failures in last 7 days + $cutoff = date('Y-m-d H:i:s', strtotime('-7 days')); + $query = $db->getQuery(true) + ->select('COUNT(*) AS fail_count') + ->from($db->quoteName('#__mokojoombackup_records')) + ->where($db->quoteName('status') . ' = ' . $db->quote('fail')) + ->where($db->quoteName('backupstart') . ' >= ' . $db->quote($cutoff)); + $db->setQuery($query); + $stats->fail_count_7d = (int) $db->loadResult(); + + return $stats; + } + + /** + * Check system health for backup readiness. + * + * @return array Array of check results [{label, status, detail}] + */ + public function getSystemHealth(): array + { + $checks = []; + + // PHP version + $checks[] = (object) [ + 'label' => 'PHP Version', + 'status' => version_compare(PHP_VERSION, '8.1.0', '>='), + 'detail' => PHP_VERSION, + ]; + + // ZipArchive extension + $checks[] = (object) [ + 'label' => 'ZipArchive', + 'status' => extension_loaded('zip'), + 'detail' => extension_loaded('zip') ? 'Loaded' : 'Not loaded', + ]; + + // AES-256 encryption support + $aesSupport = defined('ZipArchive::EM_AES_256'); + $checks[] = (object) [ + 'label' => 'AES-256 Encryption', + 'status' => $aesSupport, + 'detail' => $aesSupport ? 'Available' : 'Requires libzip 1.2.0+', + ]; + + // Backup directory writable — check the default path + $defaultDir = JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups'; + $backupDir = $defaultDir; + + // If profiles use a custom directory, check that instead + $db2 = $this->getDatabase(); + $qDir = $db2->getQuery(true) + ->select($db2->quoteName('backup_dir')) + ->from($db2->quoteName('#__mokojoombackup_profiles')) + ->where($db2->quoteName('published') . ' = 1') + ->where($db2->quoteName('backup_dir') . ' != ' . $db2->quote('')) + ->where($db2->quoteName('backup_dir') . ' IS NOT NULL'); + $db2->setQuery($qDir, 0, 1); + $profileDir = $db2->loadResult(); + + if ($profileDir) { + // Absolute paths used as-is, relative resolved from JPATH_ROOT + if ($profileDir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $profileDir)) { + $backupDir = rtrim($profileDir, '/\\'); + } else { + $backupDir = JPATH_ROOT . '/' . $profileDir; + } + } + + // Skip filesystem check if path contains placeholders (resolved at backup time) + if (preg_match('/\[.+\]/', $backupDir)) { + $checks[] = (object) [ + 'label' => 'Backup Directory', + 'status' => true, + 'detail' => 'Uses placeholders (resolved at backup time) — ' . $backupDir, + ]; + } else { + $writable = is_dir($backupDir) && is_writable($backupDir); + $checks[] = (object) [ + 'label' => 'Backup Directory', + 'status' => $writable, + 'detail' => ($writable ? 'Writable' : 'Not writable or missing') . ' — ' . $backupDir, + ]; + } + + // Disk space + $freeSpace = @disk_free_space($backupDir ?: JPATH_ROOT); + $freeGB = $freeSpace ? round($freeSpace / 1073741824, 1) : 0; + $checks[] = (object) [ + 'label' => 'Free Disk Space', + 'status' => $freeGB >= 1.0, + 'detail' => $freeGB . ' GB free', + ]; + + return $checks; + } + + /** + * Check if any profiles use the default (web-root) backup directory. + * + * @return bool + */ + public function isUsingDefaultBackupDir(): bool + { + $db = $this->getDatabase(); + $default = 'administrator/components/com_mokojoombackup/backups'; + + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokojoombackup_profiles')) + ->where($db->quoteName('published') . ' = 1') + ->where('(' . $db->quoteName('backup_dir') . ' = ' . $db->quote($default) + . ' OR ' . $db->quoteName('backup_dir') . ' = ' . $db->quote('') + . ' OR ' . $db->quoteName('backup_dir') . ' IS NULL)'); + $db->setQuery($query); + + return (int) $db->loadResult() > 0; + } + + /** + * Get published backup profiles for the quick-action selector. + * + * @return array + */ + public function getProfiles(): array + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'title', 'backup_type'])) + ->from($db->quoteName('#__mokojoombackup_profiles')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/src/packages/com_mokobackup/src/Model/ProfileModel.php b/source/packages/com_mokojoombackup/src/Model/ProfileModel.php similarity index 78% rename from src/packages/com_mokobackup/src/Model/ProfileModel.php rename to source/packages/com_mokojoombackup/src/Model/ProfileModel.php index 1935578..74cc516 100644 --- a/src/packages/com_mokobackup/src/Model/ProfileModel.php +++ b/source/packages/com_mokojoombackup/src/Model/ProfileModel.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Model; defined('_JEXEC') or die; @@ -20,7 +20,7 @@ class ProfileModel extends AdminModel public function getForm($data = [], $loadData = true) { $form = $this->loadForm( - 'com_mokobackup.profile', + 'com_mokojoombackup.profile', 'profile', ['control' => 'jform', 'load_data' => $loadData] ); @@ -30,7 +30,7 @@ class ProfileModel extends AdminModel protected function loadFormData(): object { - $data = Factory::getApplication()->getUserState('com_mokobackup.edit.profile.data', []); + $data = Factory::getApplication()->getUserState('com_mokojoombackup.edit.profile.data', []); if (empty($data)) { $data = $this->getItem(); diff --git a/src/packages/com_mokobackup/src/Model/ProfilesModel.php b/source/packages/com_mokojoombackup/src/Model/ProfilesModel.php similarity index 90% rename from src/packages/com_mokobackup/src/Model/ProfilesModel.php rename to source/packages/com_mokojoombackup/src/Model/ProfilesModel.php index 0eecaff..804d1db 100644 --- a/src/packages/com_mokobackup/src/Model/ProfilesModel.php +++ b/source/packages/com_mokojoombackup/src/Model/ProfilesModel.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Model; defined('_JEXEC') or die; @@ -38,7 +38,7 @@ class ProfilesModel extends ListModel $query = $db->getQuery(true); $query->select('a.*') - ->from($db->quoteName('#__mokobackup_profiles', 'a')); + ->from($db->quoteName('#__mokojoombackup_profiles', 'a')); $published = $this->getState('filter.published'); diff --git a/src/packages/com_mokobackup/src/Model/index.html b/source/packages/com_mokojoombackup/src/Model/index.html similarity index 100% rename from src/packages/com_mokobackup/src/Model/index.html rename to source/packages/com_mokojoombackup/src/Model/index.html diff --git a/src/packages/com_mokobackup/src/Table/BackupTable.php b/source/packages/com_mokojoombackup/src/Table/BackupTable.php similarity index 85% rename from src/packages/com_mokobackup/src/Table/BackupTable.php rename to source/packages/com_mokojoombackup/src/Table/BackupTable.php index 9ea942e..30a211a 100644 --- a/src/packages/com_mokobackup/src/Table/BackupTable.php +++ b/source/packages/com_mokojoombackup/src/Table/BackupTable.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Table; defined('_JEXEC') or die; @@ -19,7 +19,7 @@ class BackupTable extends Table { public function __construct(DatabaseDriver $db) { - parent::__construct('#__mokobackup_records', 'id', $db); + parent::__construct('#__mokojoombackup_records', 'id', $db); } public function check(): bool diff --git a/src/packages/com_mokobackup/src/Table/ProfileTable.php b/source/packages/com_mokojoombackup/src/Table/ProfileTable.php similarity index 83% rename from src/packages/com_mokobackup/src/Table/ProfileTable.php rename to source/packages/com_mokojoombackup/src/Table/ProfileTable.php index 7155b0a..41b1647 100644 --- a/src/packages/com_mokobackup/src/Table/ProfileTable.php +++ b/source/packages/com_mokojoombackup/src/Table/ProfileTable.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\Table; defined('_JEXEC') or die; @@ -19,7 +19,7 @@ class ProfileTable extends Table { public function __construct(DatabaseDriver $db) { - parent::__construct('#__mokobackup_profiles', 'id', $db); + parent::__construct('#__mokojoombackup_profiles', 'id', $db); } public function check(): bool diff --git a/src/packages/com_mokobackup/src/Table/index.html b/source/packages/com_mokojoombackup/src/Table/index.html similarity index 100% rename from src/packages/com_mokobackup/src/Table/index.html rename to source/packages/com_mokojoombackup/src/Table/index.html diff --git a/src/packages/com_mokobackup/src/View/Backup/HtmlView.php b/source/packages/com_mokojoombackup/src/View/Backup/HtmlView.php similarity index 78% rename from src/packages/com_mokobackup/src/View/Backup/HtmlView.php rename to source/packages/com_mokojoombackup/src/View/Backup/HtmlView.php index 15f25e0..5e27305 100644 --- a/src/packages/com_mokobackup/src/View/Backup/HtmlView.php +++ b/source/packages/com_mokojoombackup/src/View/Backup/HtmlView.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\View\Backup; defined('_JEXEC') or die; @@ -33,7 +33,7 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { - ToolbarHelper::title(Text::_('COM_MOKOBACKUP_BACKUP_DETAIL'), 'database'); - ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokobackup&view=backups'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUP_DETAIL'), 'database'); + ToolbarHelper::back('JTOOLBAR_BACK', 'index.php?option=com_mokojoombackup&view=backups'); } } diff --git a/src/packages/com_mokobackup/src/View/Backup/index.html b/source/packages/com_mokojoombackup/src/View/Backup/index.html similarity index 100% rename from src/packages/com_mokobackup/src/View/Backup/index.html rename to source/packages/com_mokojoombackup/src/View/Backup/index.html diff --git a/source/packages/com_mokojoombackup/src/View/Backups/HtmlView.php b/source/packages/com_mokojoombackup/src/View/Backups/HtmlView.php new file mode 100644 index 0000000..fa2b00b --- /dev/null +++ b/source/packages/com_mokojoombackup/src/View/Backups/HtmlView.php @@ -0,0 +1,118 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\View\Backups; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + protected $items; + protected $pagination; + protected $state; + public $filterForm; + public $activeFilters = []; + public $profiles = []; + + 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'); + + // Load published profiles for the backup selector + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'title', 'backup_type'])) + ->from($db->quoteName('#__mokojoombackup_profiles')) + ->where($db->quoteName('published') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + $this->profiles = $db->loadObjectList() ?: []; + + $this->checkUpdateSite(); + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Show an info notice linking to the update site record so the user + * can configure their download key for automatic updates. + */ + protected function checkUpdateSite(): void + { + try { + $db = Factory::getDbo(); + + // Find the update site linked to pkg_mokojoombackup + $query = $db->getQuery(true) + ->select([ + $db->quoteName('us.update_site_id'), + $db->quoteName('us.extra_query'), + ]) + ->from($db->quoteName('#__update_sites', 'us')) + ->join( + 'INNER', + $db->quoteName('#__update_sites_extensions', 'use') + . ' ON ' . $db->quoteName('use.update_site_id') . ' = ' . $db->quoteName('us.update_site_id') + ) + ->join( + 'INNER', + $db->quoteName('#__extensions', 'e') + . ' ON ' . $db->quoteName('e.extension_id') . ' = ' . $db->quoteName('use.extension_id') + ) + ->where($db->quoteName('e.element') . ' = ' . $db->quote('pkg_mokojoombackup')) + ->where($db->quoteName('e.type') . ' = ' . $db->quote('package')) + ->setLimit(1); + + $db->setQuery($query); + $site = $db->loadObject(); + + if (!$site) { + Factory::getApplication()->enqueueMessage( + Text::_('COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING'), + 'warning' + ); + } elseif (empty($site->extra_query) || strpos($site->extra_query, 'dlid=') === false) { + // Update site exists but no download key configured + $editUrl = Route::_( + 'index.php?option=com_installer&view=updatesites&filter[search]=mokojoombackup' + ); + + Factory::getApplication()->enqueueMessage( + Text::sprintf('COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE', $editUrl), + 'info' + ); + } + // If key is present, show nothing — all good + } catch (\Throwable $e) { + // Non-critical — silently ignore + } + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_BACKUPS_TITLE'), 'database'); + ToolbarHelper::custom('backups.start', 'download', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW', false); + ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true); + ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true); + ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete'); + ToolbarHelper::preferences('com_mokojoombackup'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Backups/index.html b/source/packages/com_mokojoombackup/src/View/Backups/index.html similarity index 100% rename from src/packages/com_mokobackup/src/View/Backups/index.html rename to source/packages/com_mokojoombackup/src/View/Backups/index.html diff --git a/source/packages/com_mokojoombackup/src/View/Dashboard/HtmlView.php b/source/packages/com_mokojoombackup/src/View/Dashboard/HtmlView.php new file mode 100644 index 0000000..ed82571 --- /dev/null +++ b/source/packages/com_mokojoombackup/src/View/Dashboard/HtmlView.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 + */ + +namespace Joomla\Component\MokoJoomBackup\Administrator\View\Dashboard; + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +class HtmlView extends BaseHtmlView +{ + public ?object $lastBackup = null; + public ?object $nextScheduled = null; + public object $stats; + public array $systemHealth = []; + public array $profiles = []; + public bool $defaultDirWarning = false; + + public function display($tpl = null): void + { + /** @var \Joomla\Component\MokoJoomBackup\Administrator\Model\DashboardModel $model */ + $model = $this->getModel(); + + $this->lastBackup = $model->getLastBackup(); + $this->nextScheduled = $model->getNextScheduled(); + $this->stats = $model->getStats(); + $this->systemHealth = $model->getSystemHealth(); + $this->profiles = $model->getProfiles(); + $this->defaultDirWarning = $model->isUsingDefaultBackupDir(); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_TITLE'), 'archive'); + ToolbarHelper::preferences('com_mokojoombackup'); + } +} diff --git a/src/packages/com_mokobackup/src/View/Profile/HtmlView.php b/source/packages/com_mokojoombackup/src/View/Profile/HtmlView.php similarity index 82% rename from src/packages/com_mokobackup/src/View/Profile/HtmlView.php rename to source/packages/com_mokojoombackup/src/View/Profile/HtmlView.php index ca959a8..7d025b1 100644 --- a/src/packages/com_mokobackup/src/View/Profile/HtmlView.php +++ b/source/packages/com_mokojoombackup/src/View/Profile/HtmlView.php @@ -2,13 +2,13 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\Administrator\View\Profile; defined('_JEXEC') or die; @@ -34,7 +34,7 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { $isNew = empty($this->item->id); - $title = $isNew ? 'COM_MOKOBACKUP_PROFILE_NEW' : 'COM_MOKOBACKUP_PROFILE_EDIT'; + $title = $isNew ? 'COM_MOKOJOOMBACKUP_PROFILE_NEW' : 'COM_MOKOJOOMBACKUP_PROFILE_EDIT'; ToolbarHelper::title(Text::_($title), 'cog'); ToolbarHelper::apply('profile.apply'); diff --git a/src/packages/com_mokobackup/src/View/Profile/index.html b/source/packages/com_mokojoombackup/src/View/Profile/index.html similarity index 100% rename from src/packages/com_mokobackup/src/View/Profile/index.html rename to source/packages/com_mokojoombackup/src/View/Profile/index.html diff --git a/src/packages/com_mokobackup/src/View/Profiles/HtmlView.php b/source/packages/com_mokojoombackup/src/View/Profiles/HtmlView.php similarity index 79% rename from src/packages/com_mokobackup/src/View/Profiles/HtmlView.php rename to source/packages/com_mokojoombackup/src/View/Profiles/HtmlView.php index 9e4bfbf..85f1b5a 100644 --- a/src/packages/com_mokobackup/src/View/Profiles/HtmlView.php +++ b/source/packages/com_mokojoombackup/src/View/Profiles/HtmlView.php @@ -2,20 +2,20 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @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; +namespace Joomla\Component\MokoJoomBackup\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; -use Joomla\Component\MokoBackup\Administrator\Engine\AkeebaImporter; +use Joomla\Component\MokoJoomBackup\Administrator\Engine\AkeebaImporter; class HtmlView extends BaseHtmlView { @@ -46,15 +46,15 @@ class HtmlView extends BaseHtmlView protected function addToolbar(): void { - ToolbarHelper::title(Text::_('COM_MOKOBACKUP_PROFILES_TITLE'), 'cog'); + ToolbarHelper::title(Text::_('COM_MOKOJOOMBACKUP_PROFILES_TITLE'), 'cog'); ToolbarHelper::addNew('profile.add'); ToolbarHelper::editList('profile.edit'); if ($this->akeebaDetected) { - ToolbarHelper::custom('profiles.importAkeeba', 'upload', '', 'COM_MOKOBACKUP_TOOLBAR_IMPORT_AKEEBA', false); + ToolbarHelper::custom('profiles.importAkeeba', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_IMPORT_AKEEBA', false); } ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'profiles.delete'); - ToolbarHelper::preferences('com_mokobackup'); + ToolbarHelper::preferences('com_mokojoombackup'); } } diff --git a/src/packages/com_mokobackup/src/View/Profiles/index.html b/source/packages/com_mokojoombackup/src/View/Profiles/index.html similarity index 100% rename from src/packages/com_mokobackup/src/View/Profiles/index.html rename to source/packages/com_mokojoombackup/src/View/Profiles/index.html diff --git a/src/packages/com_mokobackup/src/View/index.html b/source/packages/com_mokojoombackup/src/View/index.html similarity index 100% rename from src/packages/com_mokobackup/src/View/index.html rename to source/packages/com_mokojoombackup/src/View/index.html diff --git a/src/packages/com_mokobackup/src/index.html b/source/packages/com_mokojoombackup/src/index.html similarity index 100% rename from src/packages/com_mokobackup/src/index.html rename to source/packages/com_mokojoombackup/src/index.html diff --git a/source/packages/com_mokojoombackup/tmpl/backup/default.php b/source/packages/com_mokojoombackup/tmpl/backup/default.php new file mode 100644 index 0000000..2d5287f --- /dev/null +++ b/source/packages/com_mokojoombackup/tmpl/backup/default.php @@ -0,0 +1,125 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\HTML\HTMLHelper; +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; + +$ajaxToken = Session::getFormToken(); +$ajaxUrl = Route::_('index.php?option=com_mokojoombackup&format=json', false); +?> +
    +
    +

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

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + item->checksum)) : ?> + + + + + + item->remote_filename)) : ?> + + + + + + +
    + item->status) { + 'complete' => 'badge bg-success', + 'running' => 'badge bg-info', + 'fail' => 'badge bg-danger', + default => 'badge bg-secondary', + }; + ?> + escape($this->item->status); ?> +
    escape($this->item->backup_type); ?>
    escape($this->item->origin); ?>
    + item->total_size); ?> + item->db_size > 0) : ?> + (: item->db_size); ?>) + +
    item->backupstart, Text::_('DATE_FORMAT_LC2')); ?>
    item->backupend, Text::_('DATE_FORMAT_LC2')); ?>
    escape($this->item->archivename); ?>
    escape($this->item->absolute_path); ?>
    item->files_count; ?>
    item->tables_count; ?>
    escape($this->item->checksum); ?>
    escape($this->item->remote_filename); ?>
    + + +

    +
    +
    Loading...
    +
    +
    +
    + + diff --git a/src/packages/com_mokobackup/tmpl/backup/index.html b/source/packages/com_mokojoombackup/tmpl/backup/index.html similarity index 100% rename from src/packages/com_mokobackup/tmpl/backup/index.html rename to source/packages/com_mokojoombackup/tmpl/backup/index.html diff --git a/src/packages/com_mokobackup/tmpl/backups/default.php b/source/packages/com_mokojoombackup/tmpl/backups/default.php similarity index 61% rename from src/packages/com_mokobackup/tmpl/backups/default.php rename to source/packages/com_mokojoombackup/tmpl/backups/default.php index 590f519..fa79ddc 100644 --- a/src/packages/com_mokobackup/tmpl/backups/default.php +++ b/source/packages/com_mokojoombackup/tmpl/backups/default.php @@ -2,7 +2,7 @@ /** * @package MokoJoomBackup - * @subpackage com_mokobackup + * @subpackage com_mokojoombackup * @author Moko Consulting * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE @@ -19,12 +19,12 @@ use Joomla\CMS\Session\Session; HTMLHelper::_('behavior.multiselect'); $ajaxToken = Session::getFormToken(); -$ajaxUrl = Route::_('index.php?option=com_mokobackup&format=json', false); +$ajaxUrl = Route::_('index.php?option=com_mokojoombackup&format=json', false); $listOrder = $this->escape($this->state->get('list.ordering')); $listDirn = $this->escape($this->state->get('list.direction')); ?> -
    +
    @@ -33,7 +33,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
    -
    @@ -56,36 +56,36 @@ $listDirn = $this->escape($this->state->get('list.direction')); items)) : ?>
    - +
    - + -
    - + - + - + - + - + - + - + @@ -99,7 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction')); id); ?> - escape($item->description); ?> + + escape($item->description); ?> + + checksum)) : ?> +
    : checksum, 0, 16); ?>... +
    escape($item->profile_title ?? 'Profile #' . $item->profile_id); ?> @@ -130,13 +135,18 @@ $listDirn = $this->escape($this->state->get('list.direction')); backupstart, Text::_('DATE_FORMAT_LC4')); ?> + status === 'complete' && $item->filesexist) : ?> - + + id; ?> @@ -158,7 +168,7 @@ $listDirn = $this->escape($this->state->get('list.direction')); -