v01.02 — Full rename, installer, web cron, portable profiles #35

Merged
jmiller merged 84 commits from dev into main 2026-06-07 02:21:42 +00:00
252 changed files with 8915 additions and 3883 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ package-lock.json
# PHP / Composer tooling
# ============================================================
vendor/
!src/media/vendor/
!source/media/vendor/
composer.lock
*.phar
codeception.phar
+73
View File
@@ -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
+2 -2
View File
@@ -5,7 +5,7 @@
<display-name>Package - MokoJoomBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.00.00</version>
<version>01.01.21-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
@@ -16,6 +16,6 @@
<build>
<language>PHP</language>
<package-type>joomla-extension</package-type>
<entry-point>src/</entry-point>
<entry-point>source/</entry-point>
</build>
</moko-platform>
+66
View File
@@ -0,0 +1,66 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/auto-bump.yml
# VERSION: 09.23.00
# BRIEF: Auto patch-bump version on every push to dev (skips merge commits)
name: "Universal: Auto Version Bump"
on:
push:
branches:
- dev
- rc
- 'feature/**'
- 'patch/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
permissions:
contents: write
jobs:
bump:
name: Version Bump
runs-on: release
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]') &&
!startsWith(github.event.head_commit.message, 'Merge pull request')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.MOKOGITEA_TOKEN }}
fetch-depth: 1
- name: Setup moko-platform tools
run: |
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
if [ -d "/opt/moko-platform/cli" ]; then
echo "MOKO_CLI=/opt/moko-platform/cli" >> "$GITHUB_ENV"
else
git clone --depth 1 --branch main --quiet \
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/moko-platform.git" \
/tmp/moko-platform-api
cd /tmp/moko-platform-api && composer install --no-dev --no-interaction --quiet
echo "MOKO_CLI=/tmp/moko-platform-api/cli" >> "$GITHUB_ENV"
fi
- name: Bump version
run: |
php ${MOKO_CLI}/version_auto_bump.php \
--path . --branch "${GITHUB_REF_NAME}" \
--token "${{ secrets.MOKOGITEA_TOKEN }}" \
--repo-url "https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/${{ github.repository }}.git"
+285 -285
View File
@@ -1,285 +1,285 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# 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 <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/mokoconsulting-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
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 09.23.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
#
# +========================================================================+
+73
View File
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.01.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
+1 -1
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Release
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 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
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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"
+70 -38
View File
@@ -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
-84
View File
@@ -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
+90 -128
View File
@@ -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 '<version>' $(SRC_DIR)/pkg_mokojoombackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
# Default target
.DEFAULT_GOAL := help
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoJoomBackup
<!-- VERSION: 01.00.00 -->
<!-- VERSION: 01.01.21 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -0,0 +1,10 @@
; MokoJoomBackup — Package language file (en-GB)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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 <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -0,0 +1,10 @@
; MokoJoomBackup — Package language file (en-US)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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 <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -2,18 +2,18 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\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
{
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Api\View\Backups;
namespace Joomla\Component\MokoJoomBackup\Api\View\Backups;
defined('_JEXEC') or die;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
-->
<config>
<fieldset name="general" label="COM_MOKOJOOMBACKUP_CONFIG_GENERAL">
<field
name="default_backup_dir"
type="FolderPicker"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_BACKUP_DIR_DESC"
default="administrator/components/com_mokojoombackup/backups"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
/>
<field
name="default_profile"
type="sql"
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE"
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_PROFILE_DESC"
query="SELECT id AS value, title AS text FROM #__mokojoombackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
</field>
<field
name="show_update_notice"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE"
description="COM_MOKOJOOMBACKUP_CONFIG_SHOW_UPDATE_NOTICE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
<field
name="webcron_secret"
type="text"
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET"
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_SECRET_DESC"
default=""
filter="string"
maxlength="64"
/>
<field
name="webcron_enabled"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED"
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="webcron_ip_whitelist"
type="text"
label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP"
description="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_IP_DESC"
default=""
filter="string"
hint="Leave blank to allow any IP"
/>
</fieldset>
<fieldset name="cleanup" label="COM_MOKOJOOMBACKUP_CONFIG_CLEANUP">
<field
name="max_age_days"
type="number"
label="COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE"
description="COM_MOKOJOOMBACKUP_CONFIG_MAX_AGE_DESC"
default="30"
min="1"
max="365"
/>
<field
name="max_backups"
type="number"
label="COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS"
description="COM_MOKOJOOMBACKUP_CONFIG_MAX_BACKUPS_DESC"
default="10"
min="1"
max="100"
/>
</fieldset>
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS">
<field
name="notify_email"
type="text"
label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL"
description="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_EMAIL_DESC"
default=""
filter="string"
/>
<field
name="notify_on_success"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS"
description="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="notify_on_failure"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE"
description="COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
description="JCONFIG_PERMISSIONS_DESC">
<field
name="rules"
type="rules"
label="JCONFIG_PERMISSIONS_LABEL"
filter="rules"
validate="rules"
component="com_mokojoombackup"
section="component"
/>
</fieldset>
</config>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="general">
<field name="id" type="hidden" />
<field name="profile_id" type="hidden" />
<field name="description" type="text" label="COM_MOKOJOOMBACKUP_FIELD_DESCRIPTION" readonly="true" />
<field name="status" type="text" label="COM_MOKOJOOMBACKUP_FIELD_STATUS" readonly="true" />
<field name="origin" type="text" label="COM_MOKOJOOMBACKUP_FIELD_ORIGIN" readonly="true" />
<field name="backup_type" type="text" label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE" readonly="true" />
<field name="archivename" type="text" label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE" readonly="true" />
<field name="total_size" type="text" label="COM_MOKOJOOMBACKUP_FIELD_SIZE" readonly="true" />
<field name="backupstart" type="text" label="COM_MOKOJOOMBACKUP_FIELD_START" readonly="true" />
<field name="backupend" type="text" label="COM_MOKOJOOMBACKUP_FIELD_END" readonly="true" />
</fieldset>
</form>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fields name="filter">
<field
name="search"
type="text"
label="COM_MOKOJOOMBACKUP_FILTER_SEARCH"
hint="JSEARCH_FILTER"
/>
<field
name="status"
type="list"
label="COM_MOKOJOOMBACKUP_FILTER_STATUS"
onchange="this.form.submit();"
>
<option value="">COM_MOKOJOOMBACKUP_FILTER_STATUS_ALL</option>
<option value="complete">COM_MOKOJOOMBACKUP_STATUS_COMPLETE</option>
<option value="running">COM_MOKOJOOMBACKUP_STATUS_RUNNING</option>
<option value="fail">COM_MOKOJOOMBACKUP_STATUS_FAIL</option>
<option value="pending">COM_MOKOJOOMBACKUP_STATUS_PENDING</option>
</field>
</fields>
<fields name="list">
<field
name="fullordering"
type="list"
label="JGLOBAL_SORT_BY"
default="a.backupstart DESC"
onchange="this.form.submit();"
>
<option value="a.backupstart DESC">COM_MOKOJOOMBACKUP_HEADING_DATE_DESC</option>
<option value="a.backupstart ASC">COM_MOKOJOOMBACKUP_HEADING_DATE_ASC</option>
<option value="a.total_size DESC">COM_MOKOJOOMBACKUP_HEADING_SIZE_DESC</option>
<option value="a.total_size ASC">COM_MOKOJOOMBACKUP_HEADING_SIZE_ASC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
</field>
<field
name="limit"
type="limitbox"
label="JGLOBAL_LIST_LIMIT"
default="25"
onchange="this.form.submit();"
/>
</fields>
</form>
@@ -4,7 +4,7 @@
<field
name="search"
type="text"
label="COM_MOKOBACKUP_FILTER_SEARCH"
label="COM_MOKOJOOMBACKUP_FILTER_SEARCH"
hint="JSEARCH_FILTER"
/>
<field
@@ -28,8 +28,8 @@
onchange="this.form.submit();"
>
<option value="a.ordering ASC">JFIELD_ORDERING_LABEL_ASC</option>
<option value="a.title ASC">COM_MOKOBACKUP_HEADING_TITLE_ASC</option>
<option value="a.title DESC">COM_MOKOBACKUP_HEADING_TITLE_DESC</option>
<option value="a.title ASC">COM_MOKOJOOMBACKUP_HEADING_TITLE_ASC</option>
<option value="a.title DESC">COM_MOKOJOOMBACKUP_HEADING_TITLE_DESC</option>
<option value="a.id DESC">JGRID_HEADING_ID_DESC</option>
<option value="a.id ASC">JGRID_HEADING_ID_ASC</option>
</field>
@@ -0,0 +1,373 @@
<?xml version="1.0" encoding="UTF-8"?>
<form>
<fieldset name="general" label="COM_MOKOJOOMBACKUP_FIELDSET_GENERAL">
<field
name="title"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_TITLE"
description="COM_MOKOJOOMBACKUP_FIELD_TITLE_DESC"
required="true"
maxlength="255"
/>
<field
name="description"
type="textarea"
label="COM_MOKOJOOMBACKUP_FIELD_DESCRIPTION"
description="COM_MOKOJOOMBACKUP_FIELD_DESCRIPTION_DESC"
rows="3"
/>
<field
name="backup_type"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE"
description="COM_MOKOJOOMBACKUP_FIELD_BACKUP_TYPE_DESC"
default="full"
>
<option value="full">COM_MOKOJOOMBACKUP_TYPE_FULL</option>
<option value="database">COM_MOKOJOOMBACKUP_TYPE_DATABASE</option>
<option value="files">COM_MOKOJOOMBACKUP_TYPE_FILES</option>
<option value="differential">COM_MOKOJOOMBACKUP_TYPE_DIFFERENTIAL</option>
</field>
</fieldset>
<fieldset name="archive" label="COM_MOKOJOOMBACKUP_FIELDSET_ARCHIVE">
<field
name="archive_format"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC"
default="zip"
>
<option value="zip">ZIP</option>
<option value="tar.gz">tar.gz</option>
</field>
<field
name="compression_level"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_COMPRESSION"
description="COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC"
default="5"
>
<option value="0">COM_MOKOJOOMBACKUP_COMPRESSION_NONE</option>
<option value="1">COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST</option>
<option value="5">COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL</option>
<option value="9">COM_MOKOJOOMBACKUP_COMPRESSION_BEST</option>
</field>
<field
name="split_size"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE"
description="COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC"
default="0"
min="0"
hint="0 = no splitting"
/>
<field
name="backup_dir"
type="FolderPicker"
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR"
description="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC"
default="administrator/components/com_mokojoombackup/backups"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
/>
<field
name="archive_name_format"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
maxlength="512"
hint="[host]_[datetime]_profile[profile_id]"
/>
<field
name="include_mokorestore"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="encryption_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD"
description="COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC"
maxlength="255"
/>
</fieldset>
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
<field
name="id"
type="hidden"
/>
<field
name="published"
type="list"
label="JSTATUS"
default="1"
>
<option value="1">JPUBLISHED</option>
<option value="0">JUNPUBLISHED</option>
</field>
<field
name="ordering"
type="number"
label="JFIELD_ORDERING_LABEL"
default="0"
/>
</fieldset>
<fieldset name="filters" label="COM_MOKOJOOMBACKUP_FIELDSET_FILTERS">
<field
name="exclude_dirs"
type="DirectoryFilter"
label="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS"
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC"
filter="raw"
hint="tmp"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
/>
<field
name="exclude_files"
type="ExcludeList"
label="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_FILES"
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_FILES_DESC"
filter="raw"
hint="*.bak"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
/>
<field
name="exclude_tables"
type="DatabaseTables"
label="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES"
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_DESC"
filter="raw"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
/>
</fieldset>
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
<field
name="remote_storage"
type="list"
label="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE"
description="COM_MOKOJOOMBACKUP_FIELD_REMOTE_STORAGE_DESC"
default="none"
>
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
</field>
<field
name="remote_keep_local"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL"
description="COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_FIELDSET_NOTIFICATIONS">
<field
name="notify_email"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_EMAIL"
description="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_EMAIL_DESC"
maxlength="512"
hint="admin@example.com, backup@example.com"
/>
<field
name="notify_user_groups"
type="usergrouplist"
label="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS"
description="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC"
multiple="true"
layout="joomla.form.field.list-fancy-select"
/>
<field
name="notify_on_success"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS"
description="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_SUCCESS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="notify_on_failure"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE"
description="COM_MOKOJOOMBACKUP_FIELD_NOTIFY_FAILURE_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="ftp" label="COM_MOKOJOOMBACKUP_FIELDSET_FTP">
<field
name="ftp_host"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_HOST_DESC"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_port"
type="number"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PORT_DESC"
default="21"
min="1"
max="65535"
showon="remote_storage:ftp"
/>
<field
name="ftp_username"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_USERNAME"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_password"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSWORD"
maxlength="255"
showon="remote_storage:ftp"
/>
<field
name="ftp_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:ftp"
/>
<field
name="ftp_passive"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_PASSIVE_DESC"
default="1"
class="btn-group"
showon="remote_storage:ftp"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="ftp_ssl"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL"
description="COM_MOKOJOOMBACKUP_FIELD_FTP_SSL_DESC"
default="0"
class="btn-group"
showon="remote_storage:ftp"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="google_drive" label="COM_MOKOJOOMBACKUP_FIELDSET_GDRIVE">
<field
name="gdrive_client_id"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID_DESC"
maxlength="255"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_client_secret"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET"
maxlength="255"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_refresh_token"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN_DESC"
maxlength="512"
showon="remote_storage:google_drive"
/>
<field
name="gdrive_folder_id"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID"
description="COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID_DESC"
maxlength="255"
showon="remote_storage:google_drive"
/>
</fieldset>
<fieldset name="s3" label="COM_MOKOJOOMBACKUP_FIELDSET_S3">
<field
name="s3_endpoint"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT"
description="COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC"
maxlength="512"
hint="https://s3.amazonaws.com"
showon="remote_storage:s3"
/>
<field
name="s3_region"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_REGION"
description="COM_MOKOJOOMBACKUP_FIELD_S3_REGION_DESC"
default="us-east-1"
maxlength="50"
showon="remote_storage:s3"
/>
<field
name="s3_access_key"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_secret_key"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_bucket"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET"
description="COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET_DESC"
maxlength="255"
showon="remote_storage:s3"
/>
<field
name="s3_path"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_S3_PATH"
description="COM_MOKOJOOMBACKUP_FIELD_S3_PATH_DESC"
default="/backups"
maxlength="512"
showon="remote_storage:s3"
/>
</fieldset>
</form>
@@ -0,0 +1,274 @@
; MokoJoomBackup — Component language file (en-GB)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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 <a href=\"%s\">Update Site</a> 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 <a href=\"%s\">Update Site</a> 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."
@@ -0,0 +1,10 @@
; MokoJoomBackup — Component system language file (en-GB)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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"
@@ -0,0 +1,69 @@
; MokoJoomBackup — Component language file (en-US)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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 <a href=\"%s\">Update Site</a> 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 <a href=\"%s\">Update Site</a> 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."
@@ -0,0 +1,10 @@
; MokoJoomBackup — Component system language file (en-US)
; @package MokoJoomBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @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"
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
-->
<extension type="component" method="upgrade">
<name>com_mokojoombackup</name>
<version>01.01.21-dev</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<description>COM_MOKOJOOMBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Component\MokoJoomBackup</namespace>
<install>
<sql>
<file driver="mysql" charset="utf8">sql/install.mysql.sql</file>
</sql>
</install>
<uninstall>
<sql>
<file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file>
</sql>
</uninstall>
<update>
<schemas>
<schemapath type="mysql">sql/updates/mysql</schemapath>
</schemas>
</update>
<administration>
<menu img="class:archive">COM_MOKOJOOMBACKUP</menu>
<submenu>
<menu link="option=com_mokojoombackup&amp;view=dashboard" img="class:archive">COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD</menu>
<menu link="option=com_mokojoombackup&amp;view=backups" img="class:database">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokojoombackup&amp;view=profiles" img="class:cog">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
</submenu>
<files folder=".">
<folder>cli</folder>
<folder>forms</folder>
<folder>services</folder>
<folder>sql</folder>
<folder>src</folder>
<folder>tmpl</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/com_mokojoombackup.ini</language>
<language tag="en-GB">en-GB/com_mokojoombackup.sys.ini</language>
</languages>
</administration>
<api>
<files folder="api">
<folder>src</folder>
</files>
</api>
</extension>
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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));
@@ -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()
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS `#__mokojoombackup_records`;
DROP TABLE IF EXISTS `#__mokojoombackup_profiles`;
@@ -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';
@@ -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`;
@@ -0,0 +1,210 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* 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();
}
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\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';
}
@@ -2,24 +2,24 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\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));
}
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\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';
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\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';
}
@@ -2,24 +2,24 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\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));
}
/**
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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
@@ -0,0 +1,41 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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;
}
@@ -2,17 +2,18 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
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;
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
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;
}
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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 {
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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 [];
}
}
}
@@ -0,0 +1,122 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* 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);
}
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Engine;
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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.
*/
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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;
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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)) {
@@ -2,7 +2,7 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
@@ -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);
}
}
/**
@@ -0,0 +1,63 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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';
}
}
@@ -0,0 +1,47 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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';
}
}
@@ -2,18 +2,18 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Extension;
namespace Joomla\Component\MokoJoomBackup\Administrator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Extension\MVCComponent;
class MokoBackupComponent extends MVCComponent
class MokoJoomBackupComponent extends MVCComponent
{
}
@@ -0,0 +1,147 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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 = '<div class="mb-2">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<div class="form-text mb-2">' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_HELP') . '</div>';
$html .= '<div class="table-responsive" style="max-height:400px; overflow-y:auto;">';
$html .= '<table class="table table-sm table-hover mb-0">';
$html .= '<thead class="sticky-top bg-white"><tr>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleData" title="Toggle all data" /></th>';
$html .= '<th class="w-1">' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA') . '</th>';
$html .= '<th class="w-1"><input type="checkbox" id="' . $id . '_toggleStructure" title="Toggle all structure" /></th>';
$html .= '<th class="w-1">' . Text::_('COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE') . '</th>';
$html .= '<th>' . Text::_('COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME') . '</th>';
$html .= '</tr></thead><tbody>';
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 .= '<tr>';
$html .= '<td></td>';
$html .= '<td><input type="checkbox" class="' . $id . '_data" value="' . $safeValue . '"' . $dataChecked . ' /></td>';
$html .= '<td></td>';
$html .= '<td><input type="checkbox" class="' . $id . '_structure" value="' . $safeValue . '"' . $structureChecked . ' /></td>';
$html .= '<td><code>' . $safeTable . '</code></td>';
$html .= '</tr>';
}
$html .= '</tbody></table></div></div>';
// Script to sync checkboxes to hidden field
$html .= <<<SCRIPT
<script>
(function() {
var hidden = document.getElementById('{$id}');
var dataCbs = document.querySelectorAll('.{$id}_data');
var structCbs = document.querySelectorAll('.{$id}_structure');
var toggleData = document.getElementById('{$id}_toggleData');
var toggleStructure = document.getElementById('{$id}_toggleStructure');
function sync() {
var result = {};
dataCbs.forEach(function(cb) {
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 1;
});
structCbs.forEach(function(cb) {
if (cb.checked) result[cb.value] = (result[cb.value] || 0) | 2;
});
var lines = [];
for (var table in result) {
if (result[table] === 3) {
lines.push(table);
} else if (result[table] === 1) {
lines.push(table + ':data-only');
} else if (result[table] === 2) {
lines.push(table + ':structure-only');
}
}
hidden.value = lines.join('\\n');
}
dataCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
structCbs.forEach(function(cb) { cb.addEventListener('change', sync); });
toggleData.addEventListener('change', function() {
var state = this.checked;
dataCbs.forEach(function(cb) { cb.checked = state; });
sync();
});
toggleStructure.addEventListener('change', function() {
var state = this.checked;
structCbs.forEach(function(cb) { cb.checked = state; });
sync();
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}
@@ -0,0 +1,259 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* 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
<div id="{$id}_wrap">
<input type="hidden" name="{$name}" id="{$id}" value="" />
<!-- Manual entry row -->
<div class="input-group input-group-sm mb-2">
<input type="text" class="form-control" id="{$id}_manual" placeholder="{$placeholder}" />
<button type="button" class="btn btn-outline-success" id="{$id}_addBtn">
<span class="icon-plus" aria-hidden="true"></span> {$addLabel}
</button>
</div>
<!-- Selected items (pills) -->
<div id="{$id}_pills" class="mb-2 d-flex flex-wrap gap-1"></div>
<!-- Browsable tree -->
<div class="card">
<div class="card-header py-1 px-2 d-flex justify-content-between align-items-center">
<small class="fw-bold text-muted" id="{$id}_cwd"></small>
<button type="button" class="btn btn-sm btn-link p-0" id="{$id}_upBtn" style="display:none;">
<span class="icon-arrow-up-4" aria-hidden="true"></span> ..
</button>
</div>
<div id="{$id}_tree" class="list-group list-group-flush" style="max-height:300px; overflow-y:auto;"></div>
</div>
</div>
<style>
#{$id}_wrap .mb-dir-pill {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem;
font-family: monospace; cursor: default;
}
#{$id}_wrap .mb-dir-pill.excluded { background: #f8d7da; color: #842029; border: 1px solid #f5c2c7; }
#{$id}_wrap .mb-dir-pill.included { background: #d1e7dd; color: #0f5132; border: 1px solid #badbcc; }
#{$id}_wrap .mb-dir-pill .btn-close { font-size: 0.6rem; }
#{$id}_wrap .mb-dir-row { display: flex; align-items: center; padding: 0.35rem 0.75rem; gap: 0.5rem; border-bottom: 1px solid #eee; }
#{$id}_wrap .mb-dir-row:hover { background: #f8f9fa; }
#{$id}_wrap .mb-dir-row .mb-dir-name { cursor: pointer; flex: 1; font-size: 0.9rem; }
#{$id}_wrap .mb-dir-row .mb-dir-name:hover { color: #0d6efd; text-decoration: underline; }
#{$id}_wrap .mb-dir-check { width: 1rem; height: 1rem; cursor: pointer; }
</style>
<script>
(function() {
const id = '{$id}';
const hidden = document.getElementById(id);
const pills = document.getElementById(id + '_pills');
const tree = document.getElementById(id + '_tree');
const cwdEl = document.getElementById(id + '_cwd');
const upBtn = document.getElementById(id + '_upBtn');
const manualInput = document.getElementById(id + '_manual');
const addBtn = document.getElementById(id + '_addBtn');
const jRoot = {$jRoot};
let selected = new Set({$itemsJson});
let currentPath = jRoot;
let parentPath = null;
function sync() {
hidden.value = Array.from(selected).join('\\n');
renderPills();
}
function renderPills() {
while (pills.firstChild) pills.removeChild(pills.firstChild);
selected.forEach(function(path) {
const pill = document.createElement('span');
pill.className = 'mb-dir-pill excluded';
const icon = document.createElement('span');
icon.className = 'icon-folder';
icon.setAttribute('aria-hidden', 'true');
pill.appendChild(icon);
pill.appendChild(document.createTextNode(' ' + path + ' '));
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'btn-close btn-close-sm';
closeBtn.setAttribute('aria-label', 'Remove');
closeBtn.addEventListener('click', function() {
selected.delete(path);
sync();
refreshTree();
});
pill.appendChild(closeBtn);
pills.appendChild(pill);
});
}
function toRelative(absPath) {
if (absPath.indexOf(jRoot) === 0) {
let rel = absPath.substring(jRoot.length);
if (rel.charAt(0) === '/') rel = rel.substring(1);
return rel;
}
return absPath;
}
function setTreeMessage(text, cls) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
const msg = document.createElement('div');
msg.className = 'p-2 ' + cls;
msg.textContent = text;
tree.appendChild(msg);
}
function loadDir(path) {
setTreeMessage('Loading...', 'text-muted');
currentPath = path;
const form = new URLSearchParams();
form.append('task', 'ajax.browseDir');
form.append('path', path);
const tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokojoombackup&format=json', {
method: 'POST', body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
setTreeMessage(data.message || 'Error', 'text-danger');
return;
}
parentPath = data.parent || null;
cwdEl.textContent = data.current || path;
upBtn.style.display = parentPath ? '' : 'none';
renderTree(data.dirs || []);
})
.catch(function(err) {
setTreeMessage('Error: ' + err.message, 'text-danger');
});
}
function refreshTree() {
loadDir(currentPath);
}
function renderTree(dirs) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
if (dirs.length === 0) {
setTreeMessage('(empty)', 'text-muted');
return;
}
dirs.forEach(function(dir) {
const rel = toRelative(dir.path);
const isExcluded = selected.has(rel);
const row = document.createElement('div');
row.className = 'mb-dir-row' + (isExcluded ? ' bg-danger bg-opacity-10' : '');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'mb-dir-check form-check-input';
cb.checked = isExcluded;
cb.title = isExcluded ? 'Excluded — uncheck to include' : 'Check to exclude';
cb.addEventListener('change', function() {
if (cb.checked) {
selected.add(rel);
} else {
selected.delete(rel);
}
sync();
refreshTree();
});
const icon = document.createElement('span');
icon.className = isExcluded ? 'icon-unpublish text-danger' : 'icon-folder text-warning';
icon.setAttribute('aria-hidden', 'true');
const nameEl = document.createElement('span');
nameEl.className = 'mb-dir-name';
nameEl.textContent = dir.name;
nameEl.addEventListener('click', function() { loadDir(dir.path); });
row.appendChild(cb);
row.appendChild(icon);
row.appendChild(nameEl);
tree.appendChild(row);
});
}
upBtn.addEventListener('click', function() {
if (parentPath) loadDir(parentPath);
});
addBtn.addEventListener('click', function() {
const val = manualInput.value.trim();
if (val && !selected.has(val)) {
selected.add(val);
manualInput.value = '';
sync();
refreshTree();
}
});
manualInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); addBtn.click(); }
});
sync();
loadDir(jRoot);
})();
</script>
HTML;
}
}
@@ -0,0 +1,120 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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 = '<div id="' . $id . '_wrapper">';
$html .= '<input type="hidden" name="' . $name . '" id="' . $id . '" value="" />';
$html .= '<table class="table table-sm mb-1" id="' . $id . '_table">';
$html .= '<tbody>';
foreach ($items as $item) {
$safeItem = htmlspecialchars($item, ENT_QUOTES, 'UTF-8');
$html .= '<tr>';
$html .= '<td><input type="text" class="form-control form-control-sm ' . $id . '_input" value="' . $safeItem . '" placeholder="' . $placeholder . '" /></td>';
$html .= '<td class="w-1"><button type="button" class="btn btn-sm btn-outline-danger ' . $id . '_remove"><span class="icon-delete" aria-hidden="true"></span></button></td>';
$html .= '</tr>';
}
$html .= '</tbody></table>';
$html .= '<button type="button" class="btn btn-sm btn-outline-success" id="' . $id . '_add">';
$html .= '<span class="icon-plus" aria-hidden="true"></span> ' . Text::_('JGLOBAL_FIELD_ADD') . '</button>';
$html .= '</div>';
$html .= <<<SCRIPT
<script>
(function() {
var wrapper = document.getElementById('{$id}_wrapper');
var hidden = document.getElementById('{$id}');
var tbody = document.querySelector('#{$id}_table tbody');
var addBtn = document.getElementById('{$id}_add');
var placeholder = '{$placeholder}';
function sync() {
var vals = [];
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
var v = inp.value.trim();
if (v) vals.push(v);
});
hidden.value = vals.join('\\n');
}
function addRow(value) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
var inp = document.createElement('input');
inp.type = 'text';
inp.className = 'form-control form-control-sm {$id}_input';
inp.value = value || '';
inp.placeholder = placeholder;
inp.addEventListener('input', sync);
td1.appendChild(inp);
var td2 = document.createElement('td');
td2.className = 'w-1';
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-danger {$id}_remove';
var icon = document.createElement('span');
icon.className = 'icon-delete';
icon.setAttribute('aria-hidden', 'true');
btn.appendChild(icon);
btn.addEventListener('click', function() { tr.remove(); sync(); });
td2.appendChild(btn);
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
inp.focus();
}
addBtn.addEventListener('click', function() { addRow(''); });
wrapper.querySelectorAll('.{$id}_input').forEach(function(inp) {
inp.addEventListener('input', sync);
});
wrapper.querySelectorAll('.{$id}_remove').forEach(function(btn) {
btn.addEventListener('click', function() {
btn.closest('tr').remove();
sync();
});
});
sync();
})();
</script>
SCRIPT;
return $html;
}
}
@@ -0,0 +1,239 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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}: <code>{$resolvedSafe}</code>";
} 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}: <code>{$absPathSafe}</code>";
}
return <<<HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="/home/user/backups/[host] or administrator/components/com_mokojoombackup/backups" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
{$statusDetail}
</small>
</div>
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
<div class="card-body p-2">
<div id="{$id}_tree"></div>
</div>
</div>
<script>
(function() {
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
var tree = document.getElementById(fieldId + '_tree');
var input = document.getElementById(fieldId);
var placeholders = {$placeholdersJson};
// Resolve placeholders in a path (forward: [site_name] -> actual value)
function resolve(path) {
for (var key in placeholders) {
path = path.split(key).join(placeholders[key]);
}
return path;
}
// Reverse-replace actual values back to placeholders for portable storage
function unresolve(path) {
for (var key in placeholders) {
if (placeholders[key] && placeholders[key].length > 1) {
path = path.split(placeholders[key]).join(key);
}
}
return path;
}
btn.addEventListener('click', function() {
if (browser.style.display !== 'none') {
browser.style.display = 'none';
return;
}
browser.style.display = 'block';
// Resolve placeholders before browsing so the server sees real paths
loadDir(resolve(input.value || '/'));
});
function loadDir(path) {
tree.textContent = 'Loading...';
var form = new URLSearchParams();
form.append('task', 'ajax.browseDir');
form.append('path', path);
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokojoombackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
tree.textContent = data.message || 'Error loading directory';
return;
}
renderTree(data, path);
})
.catch(function(err) {
tree.textContent = 'Error: ' + err.message;
});
}
function renderTree(data, path) {
while (tree.firstChild) tree.removeChild(tree.firstChild);
var list = document.createElement('div');
list.className = 'list-group list-group-flush';
if (data.parent) {
var up = document.createElement('a');
up.href = '#';
up.className = 'list-group-item list-group-item-action py-1';
var upIcon = document.createElement('span');
upIcon.className = 'icon-arrow-up-4';
upIcon.setAttribute('aria-hidden', 'true');
up.appendChild(upIcon);
up.appendChild(document.createTextNode(' ..'));
up.addEventListener('click', function(e) {
e.preventDefault();
loadDir(data.parent);
});
list.appendChild(up);
}
(data.dirs || []).forEach(function(dir) {
var item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action py-1';
var icon = document.createElement('span');
icon.className = 'icon-folder';
icon.setAttribute('aria-hidden', 'true');
item.appendChild(icon);
item.appendChild(document.createTextNode(' ' + dir.name));
item.addEventListener('click', function(e) {
e.preventDefault();
// Store with placeholders reversed back in
input.value = unresolve(dir.path);
loadDir(dir.path);
});
item.addEventListener('dblclick', function(e) {
e.preventDefault();
input.value = unresolve(dir.path);
browser.style.display = 'none';
});
list.appendChild(item);
});
tree.appendChild(list);
var info = document.createElement('div');
info.className = 'mt-2 p-1';
var small = document.createElement('small');
small.className = 'text-muted';
small.textContent = 'Current: ' + (data.current || path);
info.appendChild(small);
// Show what will be stored (with placeholders)
var stored = document.createElement('small');
stored.className = 'text-info d-block';
stored.textContent = 'Stored as: ' + unresolve(data.current || path);
info.appendChild(stored);
tree.appendChild(info);
}
})();
</script>
HTML;
}
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Model;
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();
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Model;
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');
@@ -0,0 +1,216 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\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() ?: [];
}
}
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Model;
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();
@@ -2,13 +2,13 @@
/**
* @package MokoJoomBackup
* @subpackage com_mokobackup
* @subpackage com_mokojoombackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Component\MokoBackup\Administrator\Model;
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');

Some files were not shown because too many files have changed in this diff Show More