fix: admin submenu items and icons for Joomla 6 #45

Merged
jmiller merged 76 commits from dev into main 2026-06-16 17:06:15 +00:00
246 changed files with 4205 additions and 2570 deletions
+16 -16
View File
@@ -1,4 +1,4 @@
# MokoJoomBackup
# MokoSuiteBackup
Full-site backup and restore for Joomla — database, files, and configuration. Replaces Akeeba Backup Pro.
@@ -6,10 +6,10 @@ Full-site backup and restore for Joomla — database, files, and configuration.
| Field | Value |
|---|---|
| **Package** | `pkg_mokojoombackup` |
| **Package** | `pkg_mokosuitebackup` |
| **Language** | PHP 8.1+ |
| **Branch** | develop on `dev`, merge to `main` (protected) |
| **Wiki** | [MokoJoomBackup Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/wiki) |
| **Wiki** | [MokoSuiteBackup Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) |
## Commands
@@ -26,32 +26,32 @@ composer install # Install PHP dependencies
Joomla **package** with four sub-extensions:
### com_mokojoombackup (Component)
### com_mokosuitebackup (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
- Namespace: `Joomla\Component\MokoSuiteBackup\Administrator`
- DB tables: `#__mokosuitebackup_profiles`, `#__mokosuitebackup_records`
- CLI: `cli/mokosuitebackup.php` for cron-based backups
### plg_system_mokojoombackup (System Plugin)
### plg_system_mokosuitebackup (System Plugin)
- Cleanup of expired backup archives (age + count limits)
- Namespace: `Joomla\Plugin\System\MokoJoomBackup`
- Namespace: `Joomla\Plugin\System\MokoSuiteBackup`
### plg_task_mokojoombackup (Task Plugin)
### plg_task_mokosuitebackup (Task Plugin)
- Integrates with Joomla's Scheduled Tasks (com_scheduler)
- Registers "Run Backup Profile" task type
- Namespace: `Joomla\Plugin\Task\MokoJoomBackup`
- Namespace: `Joomla\Plugin\Task\MokoSuiteBackup`
### plg_webservices_mokojoombackup (WebServices Plugin)
- REST API for remote backup management (wire-compatible with mcp_mokojoombackup)
### plg_webservices_mokosuitebackup (WebServices Plugin)
- REST API for remote backup management (wire-compatible with mcp_mokosuitebackup)
- Endpoints: backup, backups, profiles, download, delete
- Namespace: `Joomla\Plugin\WebServices\MokoJoomBackup`
- Namespace: `Joomla\Plugin\WebServices\MokoSuiteBackup`
### Database Schema
- `#__mokojoombackup_profiles` — backup profiles (name, description, config JSON, filters JSON)
- `#__mokojoombackup_records` — backup records (profile_id, status, origin, archive path, sizes, timestamps)
- `#__mokosuitebackup_profiles` — backup profiles (name, description, config JSON, filters JSON)
- `#__mokosuitebackup_records` — backup records (profile_id, status, origin, archive path, sizes, timestamps)
## Rules
+3 -3
View File
@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<mokoplatform xmlns="https://standards.mokoconsulting.tech/mokoplatform/1.0" schema-version="1.0">
<identity>
<name>MokoJoomBackup</name>
<display-name>Package - MokoJoomBackup</display-name>
<name>MokoSuiteBackup</name>
<display-name>Package - MokoSuiteBackup</display-name>
<org>MokoConsulting</org>
<description>Full-site backup and restore for Joomla — database, files, and configuration</description>
<version>01.08.00-dev</version>
<version>01.20.00-dev</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+66 -66
View File
@@ -1,66 +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.02.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"
# 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.02.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"
+3 -3
View File
@@ -4,8 +4,8 @@
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# INGROUP: mokoplatform.Automation
# VERSION: 01.20.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Create branch and comment
run: |
TOKEN="${{ secrets.GA_TOKEN }}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
API="${GITEA_URL}/api/v1/repos/${{ github.repository }}"
ISSUE_NUM="${{ github.event.issue.number }}"
ISSUE_TITLE="${{ github.event.issue.title }}"
File diff suppressed because it is too large Load Diff
+242 -1
View File
@@ -8,4 +8,245 @@
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /templates/workflows/universal/pre-release.yml.template
# VERSION: 05.01.00
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
# BRIEF: Auto pre-release on push to dev/alpha/beta/rc branches
name: "Universal: Pre-Release"
on:
push:
branches:
- dev
- 'fix/**'
- 'patch/**'
- 'hotfix/**'
- 'bugfix/**'
- 'chore/**'
- alpha
- beta
- rc
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 || github.ref_name }})"
runs-on: release
if: >-
github.event_name == 'workflow_dispatch' ||
github.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.MOKOGITEA_TOKEN }}
ref: ${{ github.ref_name }}
- 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: |
# Auto-detect and update platform if not set in manifest
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Resolve metadata and bump version
id: meta
run: |
# Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then
case "${{ github.ref_name }}" in
rc) STABILITY="release-candidate" ;;
alpha) STABILITY="alpha" ;;
beta) STABILITY="beta" ;;
*) STABILITY="development" ;;
esac
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 "${{ github.ref_name }}" --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
+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: MokoPlatform.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
name: "RC Revert"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
revert:
name: Rename rc/ back to dev/
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == false &&
startsWith(github.event.pull_request.head.ref, 'rc/')
steps:
- name: Rename branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
SUFFIX="${BRANCH#rc/}"
DEV_BRANCH="dev/${SUFFIX}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
# Create dev/ branch from rc/ branch
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"new_branch_name\": \"${DEV_BRANCH}\", \"old_branch_name\": \"${BRANCH}\"}" \
"${API}" 2>/dev/null || true)
if [ "$STATUS" = "201" ]; then
echo "Created branch: ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::error::Failed to create ${DEV_BRANCH} from ${BRANCH} (HTTP ${STATUS})"
exit 1
fi
# Delete rc/ branch
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete ${BRANCH} (HTTP ${STATUS})"
fi
echo "### RC Reverted" >> $GITHUB_STEP_SUMMARY
echo "${BRANCH} → ${DEV_BRANCH}" >> $GITHUB_STEP_SUMMARY
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Fixed
- Admin submenu items (Dashboard, Backups, Profiles) not created on install — `<submenu>` block in manifest was empty
- Submenu items not created on update — added `ensureSubmenuItems()` using Joomla's `MenuTable` API with proper nested set positioning
- Submenu icons not rendering in Joomla 6 — set `menu_icon` param for level 2+ items (Atum only renders `img` column icons for level 1)
- CSS selector `#menu``.main-nav` for icon injection (Joomla 6 uses dynamic `id="menu{moduleId}"`)
- Use `margin-inline-end` instead of `margin-right` for RTL layout support
## [01.08.00] --- 2026-06-07
## [01.07.00] --- 2026-06-07
@@ -12,13 +19,13 @@
### Added
- Dashboard submenu entry as default landing page with `class:home` icon
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokojoombackup/backups` at runtime
- `[DEFAULT_DIR]` placeholder for portable backup directory configuration — resolves to `administrator/components/com_mokosuitebackup/backups` at runtime
- Live AJAX directory validation on backup_dir field — checks existence, writability, and placeholder resolution as user types (debounced 400ms)
- `checkDir` AJAX endpoint for real-time directory permission checking
- Web-accessible warning badge on backup download buttons when archive is inside web root
- Inline security warning in FolderPicker when default directory is selected
- Auto `.htaccess` and `index.html` protection for web-accessible backup directories on profile save and at backup time
- Font Awesome 6 submenu icons via CSS injection in `MokoJoomBackupComponent::boot()`
- Font Awesome 6 submenu icons via CSS injection in `MokoSuiteBackupComponent::boot()`
- `syncMenuIcons()` installer postflight — syncs icon classes to `#__menu` on install and update
- `encryptionPassword` property on `SteppedSession` for upcoming stepped backup encryption support
+3 -3
View File
@@ -1,6 +1,6 @@
# Contributing to MokoJoomBackup
# Contributing to MokoSuiteBackup
Thank you for your interest in contributing to MokoJoomBackup.
Thank you for your interest in contributing to MokoSuiteBackup.
## Getting Started
@@ -27,7 +27,7 @@ Thank you for your interest in contributing to MokoJoomBackup.
## Reporting Issues
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/issues).
Report bugs and feature requests via [Issues](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/issues).
## License
+5 -5
View File
@@ -2,7 +2,7 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# MokoJoomBackup — Full-site backup and restore for Joomla
# MokoSuiteBackup — 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
@@ -12,7 +12,7 @@
# CONFIGURATION
# ==============================================================================
EXTENSION_NAME := mokojoombackup
EXTENSION_NAME := mokosuitebackup
EXTENSION_TYPE := package
SRC_DIR := source
@@ -20,7 +20,7 @@ SRC_DIR := source
# Gitea
GITEA_URL := https://git.mokoconsulting.tech
GITEA_ORG := MokoConsulting
GITEA_REPO := MokoJoomBackup
GITEA_REPO := MokoSuiteBackup
# Tools
PHP := php
@@ -44,7 +44,7 @@ COLOR_RED := \033[31m
.PHONY: help
help: ## Show this help message
@echo "$(COLOR_BLUE)╔════════════════════════════════════════════════════════════╗$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ MokoJoomBackup Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)║ MokoSuiteBackup Makefile ║$(COLOR_RESET)"
@echo "$(COLOR_BLUE)╚════════════════════════════════════════════════════════════╝$(COLOR_RESET)"
@echo ""
@echo "$(COLOR_GREEN)Available targets:$(COLOR_RESET)"
@@ -158,7 +158,7 @@ release-rc: validate validate-xml ## Trigger release-candidate build via CI work
.PHONY: version
version: ## Display version from package manifest
@VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokojoombackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
@VERSION=$$(grep '<version>' $(SRC_DIR)/pkg_mokosuitebackup.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'); \
echo "$(COLOR_BLUE)$(EXTENSION_NAME)$(COLOR_RESET) v$$VERSION ($(EXTENSION_TYPE))"
# Default target
+5 -5
View File
@@ -1,12 +1,12 @@
# MokoJoomBackup
# MokoSuiteBackup
<!-- VERSION: 01.08.00 -->
<!-- VERSION: 01.20.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
## Overview
MokoJoomBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management.
MokoSuiteBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management.
## Features
@@ -25,13 +25,13 @@ MokoJoomBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It cre
## Installation
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoJoomBackup/releases)
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
2. Joomla Administrator > Extensions > Install
3. System plugin enabled automatically on install
## Configuration
- **Component**: Administrator > Components > MokoJoomBackup
- **Component**: Administrator > Components > MokoSuiteBackup
- **Profiles**: Create backup profiles with different file/database filters
- **System Plugin**: Configure scheduled backup triggers and notifications
- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups
+11
View File
@@ -0,0 +1,11 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
@@ -1,14 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage plg_webservices_mokojoombackup
* @package MokoSuiteBackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>plg_webservices_mokojoombackup</name>
<version>01.08.00</version>
<name>Web Services - MokoSuiteBackup</name>
<version>01.10.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,16 +16,16 @@
<license>GPL-3.0-or-later</license>
<description>PLG_WEBSERVICES_MOKOJOOMBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Plugin\WebServices\MokoJoomBackup</namespace>
<namespace path="src">Joomla\Plugin\WebServices\MokoSuiteBackup</namespace>
<files>
<filename plugin="mokojoombackup">mokojoombackup.php</filename>
<filename plugin="mokosuitebackup">mokosuitebackup.php</filename>
<folder>services</folder>
<folder>src</folder>
</files>
<languages>
<language tag="en-GB">language/en-GB/plg_webservices_mokojoombackup.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokojoombackup.sys.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.ini</language>
<language tag="en-GB">language/en-GB/plg_webservices_mokosuitebackup.sys.ini</language>
</languages>
</extension>
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_webservices_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\Plugin\WebServices\MokoSuiteBackup\Extension\MokoSuiteBackupWebServices;
return new class () implements ServiceProviderInterface {
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$plugin = new MokoSuiteBackupWebServices(
$container->get(DispatcherInterface::class),
(array) PluginHelper::getPlugin('webservices', 'mokosuitebackup')
);
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -1,10 +0,0 @@
; 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 @@
; MokoSuiteBackup — Package language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
PKG_MOKOJOOMBACKUP="Package - MokoSuiteBackup"
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="MokoSuiteBackup requires PHP %s or later."
PKG_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -1,10 +0,0 @@
; 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."
@@ -0,0 +1,10 @@
; MokoSuiteBackup — Package language file (en-US)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
PKG_MOKOJOOMBACKUP="Package - MokoSuiteBackup"
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="MokoSuiteBackup requires PHP %s or later."
PKG_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
@@ -1,11 +0,0 @@
; 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_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
@@ -1,11 +0,0 @@
; 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_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS `#__mokojoombackup_records`;
DROP TABLE IF EXISTS `#__mokojoombackup_profiles`;
@@ -1 +0,0 @@
ALTER TABLE `#__mokojoombackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
@@ -1,12 +0,0 @@
-- 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`;
@@ -1,45 +0,0 @@
<?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\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Factory;
class MokoJoomBackupComponent extends MVCComponent
{
public function boot(): void
{
parent::boot();
try {
$app = Factory::getApplication();
if (!$app->isClient('administrator')) {
return;
}
$wa = $app->getDocument()->getWebAssetManager();
$wa->addInlineStyle(
'#menu a[href*="com_mokojoombackup"][href*="view=dashboard"] .sidebar-item-title::before,'
. ' #menu a[href*="com_mokojoombackup"][href*="view=backups"] .sidebar-item-title::before,'
. ' #menu a[href*="com_mokojoombackup"][href*="view=profiles"] .sidebar-item-title::before'
. ' { font-family: "Font Awesome 6 Free"; font-weight: 900; margin-right: .5em; }'
. ' #menu a[href*="com_mokojoombackup"][href*="view=dashboard"] .sidebar-item-title::before { content: "\f015"; }'
. ' #menu a[href*="com_mokojoombackup"][href*="view=backups"] .sidebar-item-title::before { content: "\f1c0"; }'
. ' #menu a[href*="com_mokojoombackup"][href*="view=profiles"] .sidebar-item-title::before { content: "\f013"; }'
);
} catch (\Throwable $e) {
error_log('MokoJoomBackup: boot() CSS injection failed: ' . $e->getMessage());
}
}
}
@@ -1,153 +0,0 @@
<?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\Utility;
defined('_JEXEC') or die;
class BackupDirectory
{
public const DEFAULT_RELATIVE = 'administrator/components/com_mokojoombackup/backups';
public const PLACEHOLDER = '[DEFAULT_DIR]';
private const HTACCESS_CONTENT = <<<'HTACCESS'
# Apache 2.4+
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
HTACCESS;
private const INDEX_CONTENT = '<!DOCTYPE html><title></title>';
/**
* Get the absolute default backup directory path.
*/
public static function getDefaultAbsolute(): string
{
return JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups';
}
/**
* Resolve a backup directory path. Replaces [DEFAULT_DIR] placeholder,
* then resolves relative paths from JPATH_ROOT.
*
* @param string $dir Raw directory value from profile
*
* @return string Absolute path (may still contain other placeholders)
*/
public static function resolve(string $dir): string
{
if ($dir === '' || $dir === self::PLACEHOLDER) {
$dir = self::getDefaultAbsolute();
} else {
$dir = str_replace(self::PLACEHOLDER, self::getDefaultAbsolute(), $dir);
}
if ($dir !== '' && ($dir[0] === '/' || preg_match('#^[A-Za-z]:[/\\\\]#', $dir))) {
return rtrim($dir, '/\\');
}
return JPATH_ROOT . '/' . $dir;
}
/**
* Check whether a resolved path still contains unresolved placeholders.
*/
public static function hasPlaceholders(string $path): bool
{
return (bool) preg_match('/\[.+\]/', $path);
}
/**
* Check whether a resolved absolute path is inside the web root.
*/
public static function isWebAccessible(string $absolutePath): bool
{
$jRoot = realpath(JPATH_ROOT) ?: JPATH_ROOT;
$realDir = realpath($absolutePath) ?: $absolutePath;
return strpos($realDir, $jRoot) === 0;
}
/**
* Create .htaccess and index.html protection files in a directory.
* Only creates files if they don't already exist.
*/
public static function protect(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$htaccess = $dir . '/.htaccess';
if (!is_file($htaccess)) {
if (@file_put_contents($htaccess, self::HTACCESS_CONTENT . "\n") === false) {
error_log('MokoJoomBackup: Could not create .htaccess in: ' . $dir);
}
}
$index = $dir . '/index.html';
if (!is_file($index)) {
if (@file_put_contents($index, self::INDEX_CONTENT) === false) {
error_log('MokoJoomBackup: Could not create index.html in: ' . $dir);
}
}
}
/**
* Ensure the backup directory exists, create it if needed,
* and apply web protection if it's inside the web root.
*
* @return bool True if directory exists and is usable
*/
public static function ensureReady(string $dir): bool
{
if (!is_dir($dir)) {
if (!@mkdir($dir, 0755, true)) {
return false;
}
}
self::protect($dir);
return true;
}
/**
* Parse a newline-separated text field into an array of trimmed, non-empty strings.
*/
public static function parseNewlineList(string $text): array
{
if (empty($text)) {
return [];
}
return array_values(array_filter(
array_map('trim', explode("\n", str_replace("\r", '', $text))),
fn($line) => $line !== ''
));
}
/**
* Derive the log file path from an archive path.
*/
public static function logPathFromArchive(string $archivePath): string
{
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<access component="com_mokosuitebackup">
<section name="component">
<action name="core.admin" title="JACTION_ADMIN" />
<action name="core.options" title="JACTION_OPTIONS" />
<action name="core.manage" title="JACTION_MANAGE" />
<action name="core.create" title="JACTION_CREATE" />
<action name="core.delete" title="JACTION_DELETE" />
<action name="core.edit" title="JACTION_EDIT" />
<action name="core.edit.state" title="JACTION_EDITSTATE" />
<action name="mokosuitebackup.backup.run" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN" />
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
</section>
</access>
@@ -1,19 +1,19 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Api\Controller;
namespace Joomla\Component\MokoSuiteBackup\Api\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\ApiController;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
class BackupsController extends ApiController
{
@@ -21,10 +21,18 @@ class BackupsController extends ApiController
protected $default_view = 'backups';
/**
* Start a new backup (POST /api/index.php/v1/mokojoombackup/backup)
* Start a new backup (POST /api/index.php/v1/mokosuitebackup/backup)
*/
public function backup(): static
{
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$data = json_decode($this->input->json->getRaw(), true) ?: [];
$profileId = (int) ($data['profile'] ?? 1);
@@ -47,10 +55,18 @@ class BackupsController extends ApiController
}
/**
* Download a backup archive (GET /api/index.php/v1/mokojoombackup/backup/:id/download)
* Download a backup archive (GET /api/index.php/v1/mokosuitebackup/backup/:id/download)
*/
public function download(): static
{
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$id = $this->input->getInt('id', 0);
$model = $this->getModel('Backup', 'Administrator');
@@ -64,20 +80,42 @@ class BackupsController extends ApiController
return $this;
}
$content = base64_encode(file_get_contents($item->absolute_path));
// Stream as binary download instead of base64 to avoid memory exhaustion
while (@ob_end_clean()) {
// clear all buffers
}
$filename = basename($item->archivename ?? $item->absolute_path);
$filesize = filesize($item->absolute_path);
$contentType = str_ends_with($filename, '.tar.gz')
? 'application/gzip'
: 'application/zip';
header('Content-Type: ' . $contentType);
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename));
header('Content-Length: ' . $filesize);
header('Cache-Control: no-cache, must-revalidate');
readfile($item->absolute_path);
$this->app->setHeader('status', 200);
echo json_encode(['data' => $content]);
$this->app->close();
return $this;
}
/**
* List backup profiles (GET /api/index.php/v1/mokojoombackup/profiles)
* List backup profiles (GET /api/index.php/v1/mokosuitebackup/profiles)
*/
public function profiles(): static
{
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->app->setHeader('status', 403);
echo json_encode(['errors' => [['title' => 'Access denied']]]);
$this->app->close();
return $this;
}
$model = $this->getModel('Profiles', 'Administrator');
$items = $model->getItems();
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Api\View\Backups;
namespace Joomla\Component\MokoSuiteBackup\Api\View\Backups;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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/mokojoombackup.php --profile=1 --description="Scheduled backup"
* php cli/mokosuitebackup.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\MokoJoomBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
// Parse CLI arguments
$profileId = 1;
@@ -51,7 +51,7 @@ if (empty($description)) {
// Boot the application
$app = Factory::getApplication('administrator');
echo "MokoJoomBackup CLI\n";
echo "MokoSuiteBackup CLI\n";
echo "Profile: {$profileId}\n";
echo "Description: {$description}\n";
echo "Starting backup...\n\n";
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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
@@ -13,15 +13,15 @@
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"
default="administrator/components/com_mokosuitebackup/backups"
addfieldprefix="Joomla\Component\MokoSuiteBackup\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"
query="SELECT id AS value, title AS text FROM #__mokosuitebackup_profiles WHERE published = 1 ORDER BY ordering ASC"
default="1"
>
<option value="1">Default Backup Profile</option>
@@ -71,6 +71,31 @@
/>
</fieldset>
<fieldset name="preaction" label="COM_MOKOJOOMBACKUP_CONFIG_PREACTION">
<field
name="backup_before_update"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE"
description="COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="backup_before_uninstall"
type="radio"
label="COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL"
description="COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="cleanup" label="COM_MOKOJOOMBACKUP_CONFIG_CLEANUP">
<field
name="max_age_days"
@@ -133,7 +158,7 @@
label="JCONFIG_PERMISSIONS_LABEL"
filter="rules"
validate="rules"
component="com_mokojoombackup"
component="com_mokosuitebackup"
section="component"
/>
</fieldset>
@@ -68,7 +68,7 @@
label="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR"
description="COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC"
default="[DEFAULT_DIR]"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="archive_name_format"
@@ -129,7 +129,7 @@
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC"
filter="raw"
hint="tmp"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="exclude_files"
@@ -138,7 +138,7 @@
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_FILES_DESC"
filter="raw"
hint="*.bak"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
name="exclude_tables"
@@ -146,7 +146,7 @@
label="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES"
description="COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_TABLES_DESC"
filter="raw"
addfieldprefix="Joomla\Component\MokoJoomBackup\Administrator\Field"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
</fieldset>
@@ -215,6 +215,37 @@
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="ntfy_spacer"
type="note"
label=""
description="COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC"
class="alert alert-light border"
/>
<field
name="ntfy_topic"
type="text"
label="COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC"
description="COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC_DESC"
maxlength="255"
hint="my-backups"
/>
<field
name="ntfy_server"
type="url"
label="COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER"
description="COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER_DESC"
maxlength="512"
default="https://ntfy.sh"
hint="https://ntfy.sh"
/>
<field
name="ntfy_token"
type="password"
label="COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN"
description="COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN_DESC"
maxlength="255"
/>
</fieldset>
<fieldset name="ftp" label="COM_MOKOJOOMBACKUP_FIELDSET_FTP">
@@ -1,10 +1,10 @@
; MokoJoomBackup — Component language file (en-GB)
; @package MokoJoomBackup
; MokoSuiteBackup — Component language file (en-GB)
; @package MokoSuiteBackup
; @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="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla"
; Submenu
@@ -12,8 +12,16 @@ COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
; ACL Actions
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN="Run Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN_DESC="Allows users in this group to trigger backup operations."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD="Download Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to download backup archive files. These archives contain the full database and site files."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
; Dashboard view
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoJoomBackup Dashboard"
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard"
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
@@ -94,7 +102,7 @@ COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the
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_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. 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"
@@ -189,6 +197,13 @@ 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."
COM_MOKOJOOMBACKUP_FIELD_NTFY_SPACER_DESC="<strong>Push Notifications (ntfy)</strong> — Send instant push notifications to your phone or desktop via <a href='https://ntfy.sh' target='_blank'>ntfy.sh</a> or a self-hosted ntfy server."
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC="ntfy Topic"
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOPIC_DESC="The ntfy topic to publish notifications to. Leave blank to disable push notifications."
COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER="ntfy Server"
COM_MOKOJOOMBACKUP_FIELD_NTFY_SERVER_DESC="URL of the ntfy server. Default is the public ntfy.sh service. Use your own server URL for self-hosted instances."
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN="Access Token"
COM_MOKOJOOMBACKUP_FIELD_NTFY_TOKEN_DESC="Optional access token for private ntfy topics. Leave blank for public topics."
; Integrity verification
COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY="Verify Integrity"
@@ -216,8 +231,8 @@ COM_MOKOJOOMBACKUP_AKEEBA_NOT_FOUND="Akeeba Backup tables not found. Is Akeeba B
; 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."
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
; Component Options (config.xml)
COM_MOKOJOOMBACKUP_CONFIG_GENERAL="General"
@@ -227,6 +242,12 @@ 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_PREACTION="Pre-action Backups"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE="Backup Before Extension Update"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE_DESC="Automatically run a full backup before any extension is updated. Uses the default profile. Throttled to once per 10 minutes to prevent duplicate backups during batch updates."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
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."
@@ -245,7 +266,7 @@ 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_SECRET_DESC="The secret key required in the URL to trigger a backup. Use a long, random string. URL format: index.php?mokosuitebackup_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."
@@ -0,0 +1,19 @@
; MokoSuiteBackup — Component system language file (en-GB)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
; ACL Actions
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN="Run Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN_DESC="Allows users in this group to trigger backup operations."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD="Download Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to download backup archive files. These archives contain the full database and site files."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
@@ -1,15 +1,24 @@
; MokoJoomBackup — Component language file (en-US)
; @package MokoJoomBackup
; MokoSuiteBackup — Component language file (en-US)
; @package MokoSuiteBackup
; @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="MokoSuiteBackup"
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"
; ACL Actions
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN="Run Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN_DESC="Allows users in this group to trigger backup operations."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD="Download Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to download backup archive files. These archives contain the full database and site files."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
COM_MOKOJOOMBACKUP_DASHBOARD_TITLE="MokoSuiteBackup Dashboard"
COM_MOKOJOOMBACKUP_DASHBOARD_LAST_BACKUP="Last Backup"
COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS="No backups yet"
COM_MOKOJOOMBACKUP_DASHBOARD_NEXT_SCHEDULED="Next Scheduled"
@@ -27,8 +36,8 @@ 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_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup 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."
@@ -36,6 +45,12 @@ 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_PREACTION="Pre-action Backups"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE="Backup Before Extension Update"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UPDATE_DESC="Automatically run a full backup before any extension is updated. Uses the default profile. Throttled to once per 10 minutes to prevent duplicate backups during batch updates."
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL="Backup Before Extension Uninstall"
COM_MOKOJOOMBACKUP_CONFIG_BACKUP_BEFORE_UNINSTALL_DESC="Automatically run a full backup before any extension is uninstalled. Uses the default profile. Throttled to once per 10 minutes."
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."
@@ -0,0 +1,19 @@
; MokoSuiteBackup — Component system language file (en-US)
; @package MokoSuiteBackup
; @author Moko Consulting <hello@mokoconsulting.tech>
; @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
; @license GPL-3.0-or-later
COM_MOKOJOOMBACKUP="MokoSuiteBackup"
COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla — database, files, and configuration."
COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD="Dashboard"
COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS="Backup Records"
COM_MOKOJOOMBACKUP_SUBMENU_PROFILES="Backup Profiles"
; ACL Actions
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN="Run Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RUN_DESC="Allows users in this group to trigger backup operations."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD="Download Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD_DESC="Allows users in this group to download backup archive files. These archives contain the full database and site files."
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE="Restore Backup"
COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE_DESC="Allows users in this group to restore the site from a backup archive. This is a destructive operation that overwrites the current site."
@@ -1,14 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @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.08.00</version>
<name>MokoSuiteBackup</name>
<version>01.20.00-rc</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -17,7 +16,7 @@
<license>GPL-3.0-or-later</license>
<description>COM_MOKOJOOMBACKUP_DESCRIPTION</description>
<namespace path="src">Joomla\Component\MokoJoomBackup</namespace>
<namespace path="src">Joomla\Component\MokoSuiteBackup</namespace>
<install>
<sql>
@@ -40,11 +39,19 @@
<administration>
<menu img="class:archive">COM_MOKOJOOMBACKUP</menu>
<submenu>
<menu link="option=com_mokojoombackup&amp;view=dashboard" img="class:home">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>
<menu link="option=com_mokosuitebackup&amp;view=dashboard"
img="class:home"
alt="Dashboard">COM_MOKOJOOMBACKUP_SUBMENU_DASHBOARD</menu>
<menu link="option=com_mokosuitebackup&amp;view=backups"
img="class:database"
alt="Backups">COM_MOKOJOOMBACKUP_SUBMENU_BACKUPS</menu>
<menu link="option=com_mokosuitebackup&amp;view=profiles"
img="class:cog"
alt="Profiles">COM_MOKOJOOMBACKUP_SUBMENU_PROFILES</menu>
</submenu>
<files folder=".">
<filename>access.xml</filename>
<filename>config.xml</filename>
<folder>cli</folder>
<folder>forms</folder>
<folder>services</folder>
@@ -53,8 +60,8 @@
<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>
<language tag="en-GB">en-GB/com_mokosuitebackup.ini</language>
<language tag="en-GB">en-GB/com_mokosuitebackup.sys.ini</language>
</languages>
</administration>
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Extension\MokoJoomBackupComponent;
use Joomla\Component\MokoSuiteBackup\Administrator\Extension\MokoSuiteBackupComponent;
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\\MokoJoomBackup'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\MokoJoomBackup'));
$container->registerServiceProvider(new MVCFactory('\\Joomla\\Component\\MokoSuiteBackup'));
$container->registerServiceProvider(new ComponentDispatcherFactory('\\Joomla\\Component\\MokoSuiteBackup'));
$container->set(
ComponentInterface::class,
function (Container $container) {
$component = new MokoJoomBackupComponent(
$component = new MokoSuiteBackupComponent(
$container->get(ComponentDispatcherFactoryInterface::class)
);
$component->setMVCFactory($container->get(MVCFactoryInterface::class));
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS `#__mokojoombackup_profiles` (
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL DEFAULT '',
`description` TEXT NOT NULL,
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS `#__mokojoombackup_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_mokojoombackup/backups',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
`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',
@@ -36,6 +36,9 @@ CREATE TABLE IF NOT EXISTS `#__mokojoombackup_profiles` (
`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,
`ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name',
`ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL',
`ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional)',
`published` TINYINT(1) NOT NULL DEFAULT 1,
`ordering` INT(11) NOT NULL DEFAULT 0,
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
@@ -44,7 +47,7 @@ CREATE TABLE IF NOT EXISTS `#__mokojoombackup_profiles` (
KEY `idx_published` (`published`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `#__mokojoombackup_records` (
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_records` (
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`profile_id` INT(11) UNSIGNED NOT NULL DEFAULT 1,
`description` VARCHAR(255) NOT NULL DEFAULT '',
@@ -74,15 +77,15 @@ CREATE TABLE IF NOT EXISTS `#__mokojoombackup_records` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
INSERT IGNORE INTO `#__mokojoombackup_profiles` (
INSERT IGNORE INTO `#__mokosuitebackup_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_mokojoombackup/backups',
'administrator/components/com_mokojoombackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
'zip', 5, 0, '[DEFAULT_DIR]',
'administrator/components/com_mokosuitebackup/backups\ntmp\ncache\nlogs\nadministrator/logs',
'.gitignore\n.htaccess.bak',
'#__session',
1, 1, NOW(), NOW()
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
@@ -0,0 +1 @@
ALTER TABLE `#__mokosuitebackup_profiles` CHANGE `include_kickstart` `include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive';
@@ -0,0 +1,12 @@
-- MokoSuiteBackup 01.01.02
-- Consolidated schema updates: NULL defaults, notifications, archive name format
-- Fix: allow NULL defaults for manifest and log columns
ALTER TABLE `#__mokosuitebackup_records` MODIFY `manifest` LONGTEXT DEFAULT NULL;
ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
-- Add user group notifications column to profiles
ALTER TABLE `#__mokosuitebackup_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 `#__mokosuitebackup_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,5 @@
-- Add ntfy push notification fields to backup profiles
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `ntfy_topic` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy topic name' AFTER `notify_on_failure`,
ADD COLUMN `ntfy_server` VARCHAR(512) NOT NULL DEFAULT 'https://ntfy.sh' COMMENT 'ntfy server URL' AFTER `ntfy_topic`,
ADD COLUMN `ntfy_token` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'ntfy access token (optional, for private topics)' AFTER `ntfy_server`;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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,14 +11,14 @@
* Handles init and step requests from the admin UI JavaScript.
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class AjaxController extends BaseController
{
@@ -29,7 +29,13 @@ class AjaxController extends BaseController
public function init(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
@@ -50,7 +56,13 @@ class AjaxController extends BaseController
public function step(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
@@ -76,17 +88,26 @@ class AjaxController extends BaseController
public function browseDir(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
$requestPath = $this->input->getString('path', JPATH_ROOT);
$path = realpath($requestPath) ?: $requestPath;
// Resolve placeholders and relative paths before permission check
$resolved = BackupDirectory::resolve($requestPath);
$path = realpath($resolved) ?: $resolved;
// Security: restrict browsing to site root and current user's home
$jRoot = realpath(JPATH_ROOT);
$homeDir = getenv('HOME') ?: (getenv('USERPROFILE') ?: '');
$homeDir = BackupDirectory::getHomeDirectory();
$allowed = false;
if ($jRoot !== false && strpos($path, $jRoot) === 0) {
@@ -143,7 +164,7 @@ class AjaxController extends BaseController
if ($parent !== $path) {
if ($jRoot !== false && strpos($parent, $jRoot) === 0) {
$parentAllowed = true;
} elseif ($homeDir !== '' && strpos($parent, $homeDir) === 0) {
} elseif ($homeDir !== '' && (strpos($parent, $homeDir) === 0 || $parent === \dirname($homeDir))) {
$parentAllowed = true;
}
}
@@ -169,7 +190,13 @@ class AjaxController extends BaseController
public function viewLog(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
@@ -182,16 +209,23 @@ class AjaxController extends BaseController
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') . ' = ' . (int) $id);
$db->setQuery($query);
$record = $db->loadObject();
try {
$db = \Joomla\CMS\Factory::getDbo();
$query = $db->getQuery(true)
->select($db->quoteName(['absolute_path', 'log']))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('id') . ' = ' . (int) $id);
$db->setQuery($query);
$record = $db->loadObject();
} catch (\Exception $e) {
error_log('MokoSuiteBackup: viewLog() DB error for record ' . $id . ': ' . $e->getMessage());
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
return;
}
if (!$record) {
$this->sendJson(['error' => true, 'message' => 'Record not found']);
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
return;
}
@@ -199,18 +233,26 @@ class AjaxController extends BaseController
// Try to load log from file alongside the archive
$logPath = BackupDirectory::logPathFromArchive($record->absolute_path);
$logContent = '';
$source = 'none';
if (is_file($logPath)) {
$logContent = file_get_contents($logPath);
$content = file_get_contents($logPath);
if ($content !== false) {
$logContent = $content;
$source = 'file';
} else {
$source = 'file (read error)';
}
} elseif (!empty($record->log)) {
// Fall back to database-stored log
$logContent = $record->log;
$source = 'database';
}
$this->sendJson([
'error' => false,
'log' => $logContent ?: '(no log available)',
'source' => is_file($logPath) ? 'file' : 'database',
'source' => $source,
]);
}
@@ -221,13 +263,13 @@ class AjaxController extends BaseController
public function checkDir(): void
{
if (!Session::checkToken('get') && !Session::checkToken('post')) {
$this->sendJson(['error' => true, 'message' => 'Invalid token']);
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
return;
}
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokojoombackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied']);
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
return;
}
@@ -269,9 +311,10 @@ class AjaxController extends BaseController
/**
* Send a JSON response and close the application.
*/
private function sendJson(array $data): void
private function sendJson(array $data, int $status = 200): void
{
$app = $this->app;
$app->setHeader('status', $status);
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
$app->sendHeaders();
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
@@ -1,21 +1,22 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\RestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
class BackupsController extends AdminController
{
@@ -35,6 +36,13 @@ class BackupsController extends AdminController
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$profileId = $this->input->getInt('profile_id', 1);
$description = $this->input->getString('description', '');
@@ -47,7 +55,7 @@ class BackupsController extends AdminController
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
@@ -57,13 +65,22 @@ class BackupsController extends AdminController
*/
public function download(): void
{
$this->checkToken('get');
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.download', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$id = $this->input->getInt('id', 0);
$model = $this->getModel('Backup');
$item = $model->getItem($id);
if (!$item || !$item->id || !$item->filesexist || !is_file($item->absolute_path)) {
$this->setMessage('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
@@ -82,7 +99,7 @@ class BackupsController extends AdminController
: 'application/zip';
header('Content-Type: ' . $contentType);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename));
header('Content-Length: ' . $filesize);
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
@@ -101,6 +118,13 @@ class BackupsController extends AdminController
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$id = $this->input->getInt('id', 0);
$restoreFiles = (bool) $this->input->getInt('restore_files', 1);
$restoreDb = (bool) $this->input->getInt('restore_db', 1);
@@ -108,8 +132,8 @@ class BackupsController extends AdminController
$password = $this->input->getString('encryption_password', '');
if (!$id) {
$this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
@@ -123,7 +147,7 @@ class BackupsController extends AdminController
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
/**
@@ -133,12 +157,19 @@ class BackupsController extends AdminController
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
$cid = $this->input->get('cid', [], 'array');
$id = !empty($cid) ? (int) $cid[0] : $this->input->getInt('id', 0);
if (!$id) {
$this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
@@ -147,22 +178,22 @@ class BackupsController extends AdminController
$item = $model->getItem($id);
if (!$item || !$item->id) {
$this->setMessage('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (!is_file($item->absolute_path)) {
$this->setMessage('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
if (empty($item->checksum)) {
$this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM', 'warning');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM'), 'warning');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
return;
}
@@ -170,11 +201,11 @@ class BackupsController extends AdminController
$currentHash = hash_file('sha256', $item->absolute_path);
if ($currentHash === $item->checksum) {
$this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_OK');
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_OK'));
} else {
$this->setMessage('COM_MOKOJOOMBACKUP_VERIFY_FAILED', 'error');
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_VERIFY_FAILED'), 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=backups', false));
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
}
}
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
@@ -1,21 +1,22 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\Controller;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Controller\AdminController;
use Joomla\CMS\Router\Route;
use Joomla\Component\MokoJoomBackup\Administrator\Engine\AkeebaImporter;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\AkeebaImporter;
class ProfilesController extends AdminController
{
@@ -33,6 +34,13 @@ class ProfilesController extends AdminController
{
$this->checkToken();
if (!$this->app->getIdentity()->authorise('core.create', 'com_mokosuitebackup')) {
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=profiles', false));
return;
}
$importHistory = (bool) $this->input->getInt('import_history', 1);
$importer = new AkeebaImporter();
@@ -40,7 +48,7 @@ class ProfilesController extends AdminController
if (!$detection['profiles']) {
$this->setMessage('COM_MOKOJOOMBACKUP_AKEEBA_NOT_FOUND', 'error');
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=profiles', false));
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=profiles', false));
return;
}
@@ -55,7 +63,7 @@ class ProfilesController extends AdminController
$this->setMessage($result['message'], 'error');
}
$this->setRedirect(Route::_('index.php?option=com_mokojoombackup&view=profiles', false));
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=profiles', false));
}
/**
@@ -1,16 +1,16 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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
*
* Imports Akeeba Backup Pro profiles and backup history into MokoJoomBackup.
* Imports Akeeba Backup Pro profiles and backup history into MokoSuiteBackup.
*
* Reads from #__ak_profiles and #__ak_stats, maps Akeeba's configuration
* format to MokoJoomBackup's individual column format.
* format to MokoSuiteBackup's individual column format.
*
* Akeeba config format:
* INI-style with dot-notation keys, e.g.:
@@ -25,12 +25,12 @@
* "databases": {"include": {...}, "exclude": {...}}}
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class AkeebaImporter
{
@@ -90,7 +90,7 @@ class AkeebaImporter
}
/**
* Import all Akeeba profiles into MokoJoomBackup.
* Import all Akeeba profiles into MokoSuiteBackup.
*
* @param bool $importHistory Also import backup history from #__ak_stats
*
@@ -120,7 +120,7 @@ class AkeebaImporter
$akProfiles = $db->loadObjectList();
$profilesImported = 0;
$profileIdMap = []; // akeeba_id => mokojoombackup_id
$profileIdMap = []; // akeeba_id => mokosuitebackup_id
foreach ($akProfiles as $akProfile) {
$config = $this->parseAkeebaConfig($akProfile->configuration ?? '');
@@ -128,11 +128,11 @@ class AkeebaImporter
$mokoProfile = $this->mapToMokoProfile($akProfile, $config, $filters);
$db->insertObject('#__mokojoombackup_profiles', $mokoProfile, 'id');
$db->insertObject('#__mokosuitebackup_profiles', $mokoProfile, 'id');
$profileIdMap[$akProfile->id] = $mokoProfile->id;
$profilesImported++;
$this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoJoomBackup #' . $mokoProfile->id . ')');
$this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoSuiteBackup #' . $mokoProfile->id . ')');
}
// Import backup history
@@ -201,7 +201,7 @@ class AkeebaImporter
'log' => 'Imported from Akeeba Backup record #' . $stat->id,
];
$db->insertObject('#__mokojoombackup_records', $record, 'id');
$db->insertObject('#__mokosuitebackup_records', $record, 'id');
$imported++;
}
@@ -211,7 +211,7 @@ class AkeebaImporter
}
/**
* Map an Akeeba profile to a MokoJoomBackup profile object.
* Map an Akeeba profile to a MokoSuiteBackup profile object.
*/
private function mapToMokoProfile(object $akProfile, array $config, array $filters): object
{
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,19 +1,19 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
use Joomla\Event\Event;
class BackupEngine
@@ -47,7 +47,7 @@ class BackupEngine
// Load profile
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoombackup_profiles'))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
@@ -105,7 +105,7 @@ class BackupEngine
'log' => '',
];
$db->insertObject('#__mokojoombackup_records', $record, 'id');
$db->insertObject('#__mokosuitebackup_records', $record, 'id');
$recordId = $record->id;
try {
@@ -161,10 +161,20 @@ class BackupEngine
foreach ($filesToBackup as $relativePath) {
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) {
$archiver->addFile($fullPath, $relativePath);
} else {
if (!is_file($fullPath) || !is_readable($fullPath)) {
$skippedFiles++;
continue;
}
// Store configuration.php as .bak with credentials stripped.
// The restore process rebuilds a fresh configuration.php
// from user input + non-sensitive values from the .bak.
if ($relativePath === 'configuration.php') {
$sanitized = self::sanitizeConfiguration($fullPath);
$archiver->addFromString('configuration.php.bak', $sanitized);
$this->log('configuration.php saved as .bak (credentials stripped)');
} else {
$archiver->addFile($fullPath, $relativePath);
}
}
@@ -213,11 +223,16 @@ class BackupEngine
MokoRestore::wrap($archivePath, $mokoRestorePath);
// Replace the original archive with the wrapped one
@unlink($archivePath);
if (is_file($archivePath) && !unlink($archivePath)) {
$this->log('WARNING: Could not remove pre-wrap archive');
}
rename($mokoRestorePath, $archivePath);
$totalSize = filesize($archivePath);
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
// Recompute checksum for the final wrapped archive
$checksum = hash_file('sha256', $archivePath);
$this->log('MokoRestore archive created: ' . $sizeHuman);
$this->log('SHA-256 (wrapped): ' . $checksum);
}
$remoteFilename = '';
@@ -249,7 +264,7 @@ class BackupEngine
$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);
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
// Final record update
@@ -268,7 +283,7 @@ class BackupEngine
'log' => $logContent,
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
@@ -296,7 +311,7 @@ class BackupEngine
'log' => implode("\n", $this->log),
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send failure notification
NotificationSender::send($profile, $update, false, implode("\n", $this->log));
@@ -416,7 +431,7 @@ class BackupEngine
{
$query = $db->getQuery(true)
->select($db->quoteName('manifest'))
->from($db->quoteName('#__mokojoombackup_records'))
->from($db->quoteName('#__mokosuitebackup_records'))
->where($db->quoteName('profile_id') . ' = ' . $profileId)
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
->where($db->quoteName('manifest') . ' != ' . $db->quote(''))
@@ -472,14 +487,14 @@ class BackupEngine
}
/**
* Dispatch the onMokoJoomBackupAfterRun event so plugins (actionlog, etc.) can react.
* Dispatch the onMokoSuiteBackupAfterRun 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', [
$event = new Event('onMokoSuiteBackupAfterRun', [
'success' => $success,
'record_id' => $recordId,
'description' => $description,
@@ -487,13 +502,67 @@ class BackupEngine
'origin' => $origin,
]);
$app->getDispatcher()->dispatch('onMokoJoomBackupAfterRun', $event);
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRun', $event);
} catch (\Throwable $e) {
// Never let a listener failure break the backup result, but log it
error_log('MokoJoomBackup: onAfterRun listener error: ' . $e->getMessage());
error_log('MokoSuiteBackup: onAfterRun listener error: ' . $e->getMessage());
}
}
/**
* Sanitize configuration.php by replacing sensitive field values with
* [SANITIZED:fieldname] placeholders. Non-sensitive fields (sitename,
* debug, cache, SEF, etc.) are preserved as-is.
*
* @param string $path Absolute path to configuration.php
*
* @return string Sanitized file contents
*/
public static function sanitizeConfiguration(string $path): string
{
$content = file_get_contents($path);
if ($content === false) {
error_log('MokoSuiteBackup: sanitizeConfiguration() failed to read: ' . $path);
return '';
}
// Fields whose values must be replaced with placeholders.
// Grouped by category for maintainability.
$sensitiveFields = [
// Database
'host', 'user', 'password', 'db',
// Security
'secret',
// SMTP
'smtpuser', 'smtppass', 'smtphost',
// Proxy
'proxy_user', 'proxy_pass',
// Redis
'redis_server_auth', 'session_redis_server_auth',
// Database TLS
'dbsslkey', 'dbsslcert', 'dbsslca',
];
foreach ($sensitiveFields as $field) {
// Match: public $field = 'value'; (single-quoted)
$content = preg_replace(
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*\').*?(\';)/m',
'$1[SANITIZED:' . $field . ']$2',
$content
);
// Match: public $field = "value"; (double-quoted)
$content = preg_replace(
'/^(\s*public\s+\$' . preg_quote($field, '/') . '\s*=\s*").*?("\s*;)/m',
'$1[SANITIZED:' . $field . ']$2',
$content
);
}
return $content;
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -56,7 +56,7 @@ class DatabaseDumper
$prefix = $db->getPrefix();
$output = [];
$output[] = '-- MokoJoomBackup Database Dump';
$output[] = '-- MokoSuiteBackup Database Dump';
$output[] = '-- Generated: ' . date('Y-m-d H:i:s');
$output[] = '-- Server: ' . $db->getServerType();
$output[] = '-- Database: ' . $db->getName();
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\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('MokoJoomBackup SQL import warning: ' . $e->getMessage());
error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage());
}
}
}
@@ -115,7 +115,7 @@ class DatabaseImporter
$db->execute();
$statementsExecuted++;
} catch (\Exception $e) {
error_log('MokoJoomBackup SQL import warning (final): ' . $e->getMessage());
error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage());
}
}
} finally {
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -22,10 +22,11 @@ class FileRestorer
/**
* Files that should never be overwritten during restore.
* configuration.php is handled separately by the RestoreEngine.
* configuration.php is rebuilt from .bak + user input by RestoreEngine.
*/
private const SKIP_FILES = [
'configuration.php',
'configuration.php.bak',
'.htaccess',
'web.config',
];
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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
@@ -21,7 +21,7 @@
* with a Joomla-styled wizard interface.
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -89,7 +89,7 @@ class MokoRestore
*
* DELETE THIS FILE AFTER INSTALLATION IS COMPLETE.
*
* @package MokoJoomBackup
* @package MokoSuiteBackup
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
@@ -258,31 +258,34 @@ function actionExtract(array $data): array
$count = $zip->numFiles;
$zip->close();
// Try to read existing configuration.php for pre-filling
// Pre-fill from configuration.php.bak (sanitized backup) or
// configuration.php (legacy/unsanitized backup). Skip [SANITIZED:] values.
$existingConfig = [];
$configFile = RESTORE_DIR . '/configuration.php';
$configFile = RESTORE_DIR . '/configuration.php.bak';
if (!is_file($configFile)) {
$configFile = RESTORE_DIR . '/configuration.php';
}
if (is_file($configFile)) {
$content = file_get_contents($configFile);
if (preg_match('/\$host\s*=\s*\'([^\']*)\'/', $content, $m)) {
$existingConfig['db_host'] = $m[1];
}
$fieldMap = [
'host' => 'db_host',
'db' => 'db_name',
'user' => 'db_user',
'dbprefix' => 'db_prefix',
'sitename' => 'sitename',
'smtphost' => 'smtp_host',
'smtpuser' => 'smtp_user',
];
if (preg_match('/\$db\s*=\s*\'([^\']*)\'/', $content, $m)) {
$existingConfig['db_name'] = $m[1];
}
if (preg_match('/\$user\s*=\s*\'([^\']*)\'/', $content, $m)) {
$existingConfig['db_user'] = $m[1];
}
if (preg_match('/\$dbprefix\s*=\s*\'([^\']*)\'/', $content, $m)) {
$existingConfig['db_prefix'] = $m[1];
}
if (preg_match('/\$sitename\s*=\s*\'([^\']*)\'/', $content, $m)) {
$existingConfig['sitename'] = $m[1];
foreach ($fieldMap as $phpField => $configKey) {
if (preg_match('/\$' . preg_quote($phpField, '/') . '\s*=\s*\'([^\']*)\'/', $content, $m)) {
if (strpos($m[1], '[SANITIZED:') === false) {
$existingConfig[$configKey] = $m[1];
}
}
}
}
@@ -390,42 +393,104 @@ function actionConfig(array $data): array
$prefix = $data['db_prefix'] ?? 'moko_';
$sitename = $data['sitename'] ?? 'Joomla Site';
$livesite = $data['live_site'] ?? '';
$smtpHost = $data['smtp_host'] ?? '';
$smtpUser = $data['smtp_user'] ?? '';
$smtpPass = $data['smtp_pass'] ?? '';
$tmpPath = RESTORE_DIR . '/tmp';
$logPath = RESTORE_DIR . '/administrator/logs';
$configFile = RESTORE_DIR . '/configuration.php';
$configPath = RESTORE_DIR . '/configuration.php';
$bakPath = RESTORE_DIR . '/configuration.php.bak';
if (is_file($configFile)) {
// Update existing configuration.php
$config = file_get_contents($configFile);
// Use .bak as the base template (preserves non-sensitive settings like
// debug, cache, SEF, editor, etc.). Fall back to existing config
// for legacy/unsanitized backups, or build from scratch if neither exists.
$basePath = is_file($bakPath) ? $bakPath : (is_file($configPath) ? $configPath : null);
if ($basePath !== null) {
$config = file_get_contents($basePath);
// Replace all credential and server-specific fields with user input
// Escape all user input for safe interpolation into PHP string literals
$eHost = addcslashes($host, "'\\");
$eDbName = addcslashes($dbName, "'\\");
$eDbUser = addcslashes($dbUser, "'\\");
$eDbPass = addcslashes($dbPass, "'\\");
$ePrefix = addcslashes($prefix, "'\\");
$eSite = addcslashes($sitename, "'\\");
$eLive = addcslashes($livesite, "'\\");
$eSmtpH = addcslashes($smtpHost, "'\\");
$eSmtpU = addcslashes($smtpUser, "'\\");
$eSmtpP = addcslashes($smtpPass, "'\\");
$replacements = [
'/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$host}'",
'/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$dbName}'",
'/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$dbUser}'",
'/\$password\s*=\s*\'[^\']*\'/' => "\$password = '" . addcslashes($dbPass, "'\\") . "'",
'/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$prefix}'",
'/\$host\s*=\s*\'[^\']*\'/' => "\$host = '{$eHost}'",
'/\$db\s*=\s*\'[^\']*\'/' => "\$db = '{$eDbName}'",
'/\$user\s*=\s*\'[^\']*\'/' => "\$user = '{$eDbUser}'",
'/\$password\s*=\s*\'[^\']*\'/' => "\$password = '{$eDbPass}'",
'/\$dbprefix\s*=\s*\'[^\']*\'/' => "\$dbprefix = '{$ePrefix}'",
'/\$tmp_path\s*=\s*\'[^\']*\'/' => "\$tmp_path = '{$tmpPath}'",
'/\$log_path\s*=\s*\'[^\']*\'/' => "\$log_path = '{$logPath}'",
'/\$sitename\s*=\s*\'[^\']*\'/' => "\$sitename = '" . addcslashes($sitename, "'\\") . "'",
'/\$sitename\s*=\s*\'[^\']*\'/' => "\$sitename = '{$eSite}'",
'/\$secret\s*=\s*\'[^\']*\'/' => "\$secret = '" . bin2hex(random_bytes(16)) . "'",
];
if ($livesite !== '') {
$replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$livesite}'";
$replacements['/\$live_site\s*=\s*\'[^\']*\'/'] = "\$live_site = '{$eLive}'";
}
// SMTP — always replace (clears sanitized placeholders even if blank)
$replacements['/\$smtphost\s*=\s*\'[^\']*\'/'] = "\$smtphost = '{$eSmtpH}'";
$replacements['/\$smtpuser\s*=\s*\'[^\']*\'/'] = "\$smtpuser = '{$eSmtpU}'";
$replacements['/\$smtppass\s*=\s*\'[^\']*\'/'] = "\$smtppass = '{$eSmtpP}'";
// Clear remaining sanitized placeholders (proxy, Redis, DB TLS)
$replacements['/\$proxy_user\s*=\s*\'[^\']*\'/'] = "\$proxy_user = ''";
$replacements['/\$proxy_pass\s*=\s*\'[^\']*\'/'] = "\$proxy_pass = ''";
$replacements['/\$redis_server_auth\s*=\s*\'[^\']*\'/'] = "\$redis_server_auth = ''";
$replacements['/\$session_redis_server_auth\s*=\s*\'[^\']*\'/'] = "\$session_redis_server_auth = ''";
$replacements['/\$dbsslkey\s*=\s*\'[^\']*\'/'] = "\$dbsslkey = ''";
$replacements['/\$dbsslcert\s*=\s*\'[^\']*\'/'] = "\$dbsslcert = ''";
$replacements['/\$dbsslca\s*=\s*\'[^\']*\'/'] = "\$dbsslca = ''";
foreach ($replacements as $pattern => $replacement) {
$config = preg_replace($pattern, $replacement, $config);
}
file_put_contents($configFile, $config);
if (file_put_contents($configPath, $config) === false) {
return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions'];
}
return ['success' => true, 'message' => 'configuration.php updated with new settings and fresh secret'];
// Remove .bak after successful rebuild
if (is_file($bakPath)) {
@unlink($bakPath);
}
// Reset .htaccess to Joomla defaults if requested
$htWarn = '';
if (($data['reset_htaccess'] ?? '0') === '1') {
$htWarn = writeDefaultHtaccess(RESTORE_DIR);
}
$msg = 'Joomla configuration rebuilt with fresh credentials and secret';
if ($htWarn !== '') {
$msg .= ' (Warning: ' . $htWarn . ')';
}
return ['success' => true, 'message' => $msg];
}
// Create new configuration.php from scratch
$secret = bin2hex(random_bytes(16));
// Create new configuration.php from scratch — use escaped values
$eHost = addcslashes($host, "'\\");
$eDbName = addcslashes($dbName, "'\\");
$eDbUser = addcslashes($dbUser, "'\\");
$eDbPass = addcslashes($dbPass, "'\\");
$ePrefix = addcslashes($prefix, "'\\");
$eSite = addcslashes($sitename, "'\\");
$eLive = addcslashes($livesite, "'\\");
$secret = bin2hex(random_bytes(16));
$newConfig = <<<JCONFIG
<?php
class JConfig {
@@ -433,7 +498,7 @@ class JConfig {
public \$offline_message = 'This site is down for maintenance.<br>Please check back again soon.';
public \$display_offline_message = 1;
public \$offline_image = '';
public \$sitename = '{$sitename}';
public \$sitename = '{$eSite}';
public \$editor = 'tinymce';
public \$captcha = '0';
public \$list_limit = 20;
@@ -442,11 +507,11 @@ class JConfig {
public \$debug_lang = false;
public \$debug_lang_const = true;
public \$dbtype = 'mysqli';
public \$host = '{$host}';
public \$user = '{$dbUser}';
public \$password = '{$dbPass}';
public \$db = '{$dbName}';
public \$dbprefix = '{$prefix}';
public \$host = '{$eHost}';
public \$user = '{$eDbUser}';
public \$password = '{$eDbPass}';
public \$db = '{$eDbName}';
public \$dbprefix = '{$ePrefix}';
public \$dbencryption = 0;
public \$dbsslverifyservercert = false;
public \$dbsslkey = '';
@@ -454,7 +519,7 @@ class JConfig {
public \$dbsslca = '';
public \$dbsslcipher = '';
public \$force_ssl = 0;
public \$live_site = '{$livesite}';
public \$live_site = '{$eLive}';
public \$secret = '{$secret}';
public \$gzip = false;
public \$error_reporting = 'default';
@@ -468,19 +533,161 @@ class JConfig {
}
JCONFIG;
file_put_contents($configFile, $newConfig);
if (file_put_contents($configPath, $newConfig) === false) {
return ['success' => false, 'message' => 'Failed to write Joomla config file — check directory permissions'];
}
// Ensure directories exist
@mkdir($tmpPath, 0755, true);
@mkdir($logPath, 0755, true);
return ['success' => true, 'message' => 'configuration.php created from scratch with fresh secret'];
// Reset .htaccess to Joomla defaults if requested
$htWarn = '';
if (($data['reset_htaccess'] ?? '0') === '1') {
$htWarn = writeDefaultHtaccess(RESTORE_DIR);
}
$msg = 'Joomla configuration created from scratch with fresh secret';
if ($htWarn !== '') {
$msg .= ' (Warning: ' . $htWarn . ')';
}
return ['success' => true, 'message' => $msg];
}
/**
* Write a clean Joomla default .htaccess file.
* Backs up the existing one as .htaccess.bak first.
*/
function writeDefaultHtaccess(string $siteRoot): string
{
$htaccess = $siteRoot . '/.htaccess';
// Backup existing .htaccess before overwriting
if (is_file($htaccess)) {
if (!copy($htaccess, $htaccess . '.bak')) {
return 'Could not back up existing .htaccess — reset skipped for safety';
}
}
$default = <<<'HTACCESS'
##
# @package Joomla
# @copyright (C) 2005 Open Source Matters, Inc. <https://www.joomla.org>
# @license GNU General Public License version 2 or later; see LICENSE.txt
##
##
# READ THIS COMPLETELY IF YOU CHOOSE TO USE THIS FILE!
#
# The line 'Options +FollowSymLinks' may cause problems with some server
# configurations. It is required for the use of Apache mod_rewrite, but
# it may have already been set by your server administrator in a way that
# disallows changing it in this .htaccess file. If using it causes your
# server to report an error, comment it out, reload your site in your
# browser and test your SEF URLs. If they work, then it has been set by
# your server administrator and you do not need to set it here.
##
## No directory listings
<IfModule autoindex>
IndexIgnore *
</IfModule>
## Suppress mime type detection in browsers for unknown types
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
</IfModule>
## Can be commented out if causes errors, see notes above.
Options +FollowSymLinks
Options -Indexes
## Disable inline JavaScript when directly opening SVG files or embedding them with the object-tag
<FilesMatch "\.svg$">
<IfModule mod_headers.c>
Header always set Content-Security-Policy "script-src 'none'"
</IfModule>
</FilesMatch>
## Mod_rewrite in use.
RewriteEngine On
## Begin - Rewrite rules to block out some common exploits.
# If you experience problems on your site then comment out the operations listed
# below by adding a # to the beginning of the line.
# This attempts to block the most common type of exploit `attempts` on Joomla!
#
# Block any script trying to base64_encode data within the URL.
RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR]
# Block any script that includes a <script> tag in URL.
RewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]
# Block any script trying to set a PHP GLOBALS variable via URL.
RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
# Block any script trying to modify a _REQUEST variable via URL.
RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
# Return 403 Forbidden header and show the content of the root home page
RewriteRule .* index.php [F]
#
## End - Rewrite rules to block out some common exploits.
## Begin - Custom redirects
#
# If you need to redirect some pages, or set a canonical non-www to
# www redirect (or vice versa), place that code here. Ensure those
# redirects use the correct RewriteRule syntax and the [R=301,L] flags.
#
## End - Custom redirects
##
# Uncomment the following line if your webserver's URL
# is not directly related to physical file paths.
# Update Your Joomla! Directory (just / for root).
##
# RewriteBase /
## Begin - Joomla! core SEF Section.
#
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
#
# If the requested path and file is not /index.php and the request
# has not already been internally rewritten to the index.php script
RewriteCond %{REQUEST_URI} !^/index\.php
# and the requested path and file matches no existing file
RewriteCond %{REQUEST_FILENAME} !-f
# and the requested path and file matches no existing directory
RewriteCond %{REQUEST_FILENAME} !-d
# internally rewrite the request to the index.php script
RewriteRule .* index.php [L]
#
## End - Joomla! core SEF Section.
HTACCESS;
if (file_put_contents($htaccess, $default) === false) {
return 'Could not write .htaccess — check directory permissions';
}
return '';
}
function getValidatedPrefix(array $data): string
{
$prefix = getValidatedPrefix($data);
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
}
return $prefix;
}
function actionListAdmins(array $data): array
{
$pdo = getDbConnection($data);
$prefix = $data['db_prefix'] ?? 'moko_';
$prefix = getValidatedPrefix($data);
// Find super admin users (group 8 = Super Users in Joomla)
$stmt = $pdo->prepare(
@@ -526,7 +733,7 @@ function actionResetAdmin(array $data): array
function actionProvision(array $data): array
{
$pdo = getDbConnection($data);
$prefix = $data['db_prefix'] ?? 'moko_';
$prefix = getValidatedPrefix($data);
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
$results = [];
@@ -625,20 +832,12 @@ function actionCleanup(): array
function getDbConnection(array $data): PDO
{
$host = $data['db_host'] ?? 'localhost';
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
$name = $data['db_name'] ?? '';
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
$user = $data['db_user'] ?? '';
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
$pass = $data['db_pass'] ?? '';
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
// Validate db_prefix to prevent SQL injection
$prefix = $data['db_prefix'] ?? 'moko_';
// Validate db_prefix to prevent SQL injection $prefix = $data['db_prefix'] ?? 'moko_'; if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$/', $prefix)) { throw new RuntimeException('Invalid table prefix format'); }
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]{0,20}$\/', $prefix)) {
throw new RuntimeException('Invalid table prefix format');
}
// Validate db_prefix to prevent SQL injection (used by callers for table names)
getValidatedPrefix($data);
return new PDO(
"mysql:host={$host};dbname={$name};charset=utf8mb4",
@@ -763,7 +962,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<body>
<div class="mr-header">
<h1>MokoRestore</h1>
<p>Standalone Site Installer &mdash; MokoJoomBackup</p>
<p>Standalone Site Installer &mdash; MokoSuiteBackup</p>
</div>
<div class="mr-container">
@@ -837,15 +1036,44 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<!-- Step 4: Site Configuration -->
<div class="mr-panel" id="panel4">
<h2>Site Configuration</h2>
<p class="mr-desc">Update or create configuration.php with the correct settings for this server.</p>
<div class="mr-field"><label>Site Name</label><input type="text" id="siteName" value="Joomla Site"></div>
<div class="mr-field">
<label>Live Site URL <span style="font-weight:normal;color:#94a3b8">(optional)</span></label>
<input type="text" id="liveSite" placeholder="https://example.com">
<div class="mr-hint">Leave blank to auto-detect. Set this if using a reverse proxy or custom domain.</div>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#127760;</span> General
</div>
<div class="mr-field"><label>Site Name</label><input type="text" id="siteName" value="Joomla Site"></div>
<div class="mr-field">
<label>Live Site URL <span style="font-weight:normal;color:#94a3b8">(optional)</span></label>
<input type="text" id="liveSite" placeholder="https://example.com">
<div class="mr-hint">Leave blank to auto-detect. Set this if using a reverse proxy or custom domain.</div>
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#9993;</span> Mail / SMTP <span style="font-weight:normal;font-size:0.8rem;color:#94a3b8">&mdash; leave blank if using PHP mail()</span>
</div>
<div class="mr-field"><label>SMTP Host</label><input type="text" id="smtpHost" placeholder="smtp.example.com"></div>
<div class="mr-row">
<div class="mr-field"><label>SMTP User</label><input type="text" id="smtpUser" placeholder="user@example.com"></div>
<div class="mr-field"><label>SMTP Password</label><input type="password" id="smtpPass" placeholder=""></div>
</div>
</div>
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:1.25rem;margin-bottom:1.25rem;background:#f8fafc">
<div style="font-weight:600;font-size:0.9rem;color:#334155;margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem">
<span style="font-size:1.1rem">&#128736;</span> Server
</div>
<div class="mr-field" style="display:flex;align-items:center;gap:0.5rem">
<input type="checkbox" id="resetHtaccess" style="width:auto">
<label for="resetHtaccess" style="margin:0;cursor:pointer">Reset .htaccess to Joomla defaults</label>
</div>
<div class="mr-hint">Check this if restoring to a different server. The backup's .htaccess may contain server-specific rewrite rules that won't work here.</div>
</div>
<div class="mr-alert mr-alert-info">
A new Joomla secret will be generated automatically for security.
<span>&#128274;</span> A new Joomla secret key will be generated automatically. This invalidates active sessions (users will need to log in again) but does not affect passwords or user accounts.
</div>
<div class="mr-status" id="configStatus"></div>
<div class="mr-actions">
@@ -927,7 +1155,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-footer">
MokoRestore &mdash; <a href="https://mokoconsulting.tech" target="_blank">Moko Consulting</a>
&mdash; Part of MokoJoomBackup
&mdash; Part of MokoSuiteBackup
</div>
<script>
@@ -1057,13 +1285,16 @@ async function runExtract() {
setStatus('extractStatus', r.message, 'success');
log(r.message);
// Pre-fill DB config from extracted configuration.php
// Pre-fill config from extracted configuration.php
// (sanitized fields will be absent — those form fields stay empty)
if (r.config) {
if (r.config.db_host) document.getElementById('dbHost').value = r.config.db_host;
if (r.config.db_name) document.getElementById('dbName').value = r.config.db_name;
if (r.config.db_user) document.getElementById('dbUser').value = r.config.db_user;
if (r.config.db_prefix) document.getElementById('dbPrefix').value = r.config.db_prefix;
if (r.config.sitename) document.getElementById('siteName').value = r.config.sitename;
if (r.config.smtp_host) document.getElementById('smtpHost').value = r.config.smtp_host;
if (r.config.smtp_user) document.getElementById('smtpUser').value = r.config.smtp_user;
}
if (!r.has_db) {
@@ -1130,6 +1361,10 @@ async function runConfig() {
const params = Object.assign({}, dbConfig, {
sitename: document.getElementById('siteName').value,
live_site: document.getElementById('liveSite').value,
smtp_host: document.getElementById('smtpHost').value,
smtp_user: document.getElementById('smtpUser').value,
smtp_pass: document.getElementById('smtpPass').value,
reset_htaccess: document.getElementById('resetHtaccess').checked ? '1' : '0',
});
const r = await post('config', params);
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -32,6 +32,14 @@ class NotificationSender
* @return bool True if email was sent
*/
public static function send(object $profile, object $record, bool $success, string $logText = ''): bool
{
$emailSent = self::sendEmail($profile, $record, $success, $logText);
$ntfySent = self::sendNtfy($profile, $record, $success);
return $emailSent || $ntfySent;
}
private static function sendEmail(object $profile, object $record, bool $success, string $logText = ''): bool
{
$notifyEmail = trim($profile->notify_email ?? '');
$notifyUserGroups = $profile->notify_user_groups ?? '';
@@ -73,7 +81,7 @@ class NotificationSender
// Build subject
$statusLabel = $success ? 'SUCCESS' : 'FAILED';
$mailer->setSubject("[MokoJoomBackup] {$statusLabel}: {$record->description}{$siteName}");
$mailer->setSubject("[MokoSuiteBackup] {$statusLabel}: {$record->description}{$siteName}");
// Build body
$duration = '';
@@ -92,7 +100,7 @@ class NotificationSender
? number_format($record->total_size / 1048576, 2) . ' MB'
: 'N/A';
$body = "MokoJoomBackup Notification\n"
$body = "MokoSuiteBackup Notification\n"
. "============================\n\n"
. "Site: {$siteName}\n"
. "URL: {$siteUrl}\n"
@@ -125,7 +133,7 @@ class NotificationSender
}
$body .= "\n--\n"
. "MokoJoomBackup — https://mokoconsulting.tech\n";
. "MokoSuiteBackup — https://mokoconsulting.tech\n";
$mailer->setBody($body);
$mailer->isHtml(false);
@@ -133,12 +141,95 @@ class NotificationSender
return $mailer->Send();
} catch (\Throwable $e) {
// Don't let notification failure break the backup flow
error_log('MokoJoomBackup notification error: ' . $e->getMessage());
error_log('MokoSuiteBackup notification error: ' . $e->getMessage());
return false;
}
}
/**
* Send a push notification via ntfy.
*/
private static function sendNtfy(object $profile, object $record, bool $success): bool
{
$topic = trim($profile->ntfy_topic ?? '');
$server = trim($profile->ntfy_server ?? 'https://ntfy.sh');
$token = trim($profile->ntfy_token ?? '');
if ($topic === '') {
return false;
}
// Respect the same success/failure preferences as email
if ($success && empty($profile->notify_on_success)) {
return false;
}
if (!$success && empty($profile->notify_on_failure)) {
return false;
}
try {
$config = Factory::getApplication()->getConfig();
$siteName = $config->get('sitename', 'Joomla Site');
$statusLabel = $success ? 'SUCCESS' : 'FAILED';
$statusEmoji = $success ? "\xE2\x9C\x85" : "\xE2\x9D\x8C";
$sizeHuman = $record->total_size > 0
? number_format($record->total_size / 1048576, 2) . ' MB'
: 'N/A';
$title = "{$statusEmoji} Backup {$statusLabel}: {$siteName}";
$body = "Profile: {$profile->title}\n"
. "Type: {$record->backup_type}\n"
. "Archive: {$record->archivename}\n"
. "Size: {$sizeHuman}";
$url = rtrim($server, '/') . '/' . rawurlencode($topic);
$headers = [
'Title: ' . $title,
'Priority: ' . ($success ? '3' : '5'),
'Tags: ' . ($success ? 'white_check_mark' : 'rotating_light'),
];
if ($token !== '') {
$headers[] = 'Authorization: Bearer ' . $token;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error !== '') {
error_log('MokoSuiteBackup: ntfy error: ' . $error);
return false;
}
if ($httpCode < 200 || $httpCode >= 300) {
error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . $response);
return false;
}
return true;
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: ntfy notification error: ' . $e->getMessage());
return false;
}
}
/**
* Resolve user group IDs to email addresses of group members.
*
@@ -172,7 +263,7 @@ class NotificationSender
return $db->loadColumn() ?: [];
} catch (\Throwable $e) {
error_log('MokoJoomBackup: Could not resolve user group emails: ' . $e->getMessage());
error_log('MokoSuiteBackup: Could not resolve user group emails: ' . $e->getMessage());
return [];
}
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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,12 +11,12 @@
* directory paths and archive filename formats.
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class PlaceholderResolver
{
@@ -40,6 +40,7 @@ class PlaceholderResolver
'[type]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string',
'[DEFAULT_DIR]' => 'Default backup directory',
'[HOME]' => 'Home directory of the PHP process owner',
];
private array $replacements;
@@ -77,6 +78,7 @@ class PlaceholderResolver
'[type]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)),
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
];
}
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -57,7 +57,7 @@ class RestoreEngine
// Load backup record
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoombackup_records'))
->from($db->quoteName('#__mokosuitebackup_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/mokojoombackup-restore-' . $record->tag;
$this->stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $record->tag;
if (is_dir($this->stagingDir)) {
$this->recursiveDelete($this->stagingDir);
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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,12 +16,12 @@
* where ini_set() and set_time_limit() are disabled.
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class SteppedBackupEngine
{
@@ -37,7 +37,7 @@ class SteppedBackupEngine
// Load profile
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoombackup_profiles'))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
@@ -100,7 +100,7 @@ class SteppedBackupEngine
'log' => '',
];
$db->insertObject('#__mokojoombackup_records', $record, 'id');
$db->insertObject('#__mokosuitebackup_records', $record, 'id');
$session->recordId = $record->id;
// Determine what work needs to be done and estimate steps
@@ -228,7 +228,7 @@ class SteppedBackupEngine
$flags = $session->tableIndex === 0 ? 0 : FILE_APPEND;
if ($session->tableIndex === 0) {
$header = "-- MokoJoomBackup Database Dump\n"
$header = "-- MokoSuiteBackup Database Dump\n"
. "-- Generated: " . date('Y-m-d H:i:s') . "\n"
. "-- Prefix: " . $db->getPrefix() . "\n\n"
. "SET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\n"
@@ -277,10 +277,19 @@ class SteppedBackupEngine
foreach ($batch as $relativePath) {
$fullPath = JPATH_ROOT . '/' . $relativePath;
if (is_file($fullPath) && is_readable($fullPath)) {
$zip->addFile($fullPath, $relativePath);
$added++;
if (!is_file($fullPath) || !is_readable($fullPath)) {
continue;
}
// Store config as .bak with credentials stripped — restore rebuilds it
if (basename($relativePath) === 'configuration.php' && dirname($relativePath) === '.') {
$sanitized = BackupEngine::sanitizeConfiguration($fullPath);
$zip->addFromString('configuration.php.bak', $sanitized);
} else {
$zip->addFile($fullPath, $relativePath);
}
$added++;
}
$zip->close();
@@ -315,7 +324,7 @@ class SteppedBackupEngine
// Clean up temp SQL file
if (is_file($sqlFile) && !@unlink($sqlFile)) {
error_log('MokoJoomBackup: Could not delete temp SQL file: ' . $sqlFile);
error_log('MokoSuiteBackup: Could not delete temp SQL file: ' . $sqlFile);
}
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
@@ -344,7 +353,7 @@ class SteppedBackupEngine
'filesexist' => 1,
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->currentStep++;
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
@@ -366,7 +375,7 @@ class SteppedBackupEngine
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokojoombackup_profiles'))
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
@@ -402,7 +411,7 @@ class SteppedBackupEngine
'filesexist' => is_file($session->archivePath) ? 1 : 0,
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
$session->currentStep++;
$session->phase = 'complete';
@@ -421,7 +430,7 @@ class SteppedBackupEngine
// Write log file alongside the archive
$logPath = BackupDirectory::logPathFromArchive($session->archivePath);
if (@file_put_contents($logPath, $logContent) === false) {
error_log('MokoJoomBackup: Could not write log file: ' . $logPath);
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
}
$update = (object) [
@@ -431,7 +440,7 @@ class SteppedBackupEngine
'log' => $logContent,
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
}
/**
@@ -447,7 +456,7 @@ class SteppedBackupEngine
'log' => implode("\n", $session->log),
];
$db->updateObject('#__mokojoombackup_records', $update, 'id');
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
}
/**
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoJoomBackup\Administrator\Engine;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -63,7 +63,7 @@ class SteppedSession
private static function getSessionDir(): string
{
$dir = JPATH_ROOT . '/tmp/mokojoombackup-sessions';
$dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions';
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true)) {
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
@@ -0,0 +1,52 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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\MokoSuiteBackup\Administrator\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Document\HtmlDocument;
use Joomla\CMS\Extension\MVCComponent;
use Joomla\CMS\Factory;
class MokoSuiteBackupComponent extends MVCComponent
{
public function boot(): void
{
parent::boot();
try {
$app = Factory::getApplication();
if (!$app->isClient('administrator')) {
return;
}
$doc = $app->getDocument();
if (!($doc instanceof HtmlDocument)) {
return;
}
$wa = $doc->getWebAssetManager();
$wa->addInlineStyle(
'.main-nav a[href*="com_mokosuitebackup"][href*="view=dashboard"] .sidebar-item-title::before,'
. ' .main-nav a[href*="com_mokosuitebackup"][href*="view=backups"] .sidebar-item-title::before,'
. ' .main-nav a[href*="com_mokosuitebackup"][href*="view=profiles"] .sidebar-item-title::before'
. ' { font-family: "Font Awesome 6 Free"; font-weight: 900; margin-inline-end: .5em; }'
. ' .main-nav a[href*="com_mokosuitebackup"][href*="view=dashboard"] .sidebar-item-title::before { content: "\f015"; }'
. ' .main-nav a[href*="com_mokosuitebackup"][href*="view=backups"] .sidebar-item-title::before { content: "\f1c0"; }'
. ' .main-nav a[href*="com_mokosuitebackup"][href*="view=profiles"] .sidebar-item-title::before { content: "\f013"; }'
);
} catch (\Exception $e) {
error_log('MokoSuiteBackup: boot() CSS injection failed: ' . $e->getMessage());
}
}
}
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
@@ -1,8 +1,8 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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 @@
* Loads the directory tree from the server via AJAX (browseDir endpoint).
*/
namespace Joomla\Component\MokoJoomBackup\Administrator\Field;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
@@ -163,7 +163,7 @@ class DirectoryFilterField extends FormField
const tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokojoombackup&format=json', {
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST', body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
@@ -1,21 +1,21 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
use Joomla\Component\MokoJoomBackup\Administrator\Utility\BackupDirectory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class FolderPickerField extends FormField
{
@@ -51,6 +51,7 @@ class FolderPickerField extends FormField
$placeholders = [
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
'[host]' => $hostname,
'[site_name]' => $sanitizedSiteName ?: 'joomla',
'[profile_id]' => '1',
@@ -90,11 +91,14 @@ class FolderPickerField extends FormField
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
placeholder="[DEFAULT_DIR] or /home/user/backups/[host]" />
placeholder="[HOME]/backups or [DEFAULT_DIR]" />
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
<span class="icon-question-circle" aria-hidden="true"></span>
</button>
</div>
<div class="mt-1" id="{$id}_status">
<small class="{$statusClass}">
@@ -106,6 +110,44 @@ class FolderPickerField extends FormField
<span class="icon-warning-circle" aria-hidden="true"></span>
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
</div>
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
<table class="table table-sm table-striped">
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
<tbody>
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
</tbody>
</table>
<h6>Recommended Paths</h6>
<ul class="list-unstyled">
<li><code>[HOME]/backups</code> Outside web root (recommended)</li>
<li><code>[HOME]/backups/[host]</code> Per-site subdirectory</li>
<li><code>[DEFAULT_DIR]</code> Inside web root (protected by .htaccess)</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</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>
@@ -162,8 +204,12 @@ class FolderPickerField extends FormField
function setDefaultDirWarning() {
var warn = document.getElementById(fieldId + '_defaultwarn');
var val = input.value.trim();
var isDefault = (!val || val === '[DEFAULT_DIR]' || val === 'administrator/components/com_mokojoombackup/backups');
if (warn) warn.style.display = isDefault ? 'block' : 'none';
var resolved = resolve(val);
var jRoot = placeholders['[DEFAULT_DIR]'].replace(/\/administrator\/components\/com_mokosuitebackup\/backups$/, '');
var isInsideWebRoot = resolved && resolved.indexOf(jRoot) === 0;
var isOldDefault = val === 'administrator/components/com_mokosuitebackup/backups' || val === 'administrator/components/com_mokojoombackup/backups' || val === '[DEFAULT_DIR]';
var showWarning = isOldDefault || isInsideWebRoot;
if (warn) warn.style.display = showWarning ? 'block' : 'none';
}
function checkDirPermissions() {
@@ -179,7 +225,7 @@ class FolderPickerField extends FormField
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokojoombackup&format=json', {
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
@@ -237,7 +283,7 @@ class FolderPickerField extends FormField
var tokenName = Joomla.getOptions('csrf.token') || '';
if (tokenName) form.append(tokenName, '1');
fetch('index.php?option=com_mokojoombackup&format=json', {
fetch('index.php?option=com_mokosuitebackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
@@ -1,14 +1,14 @@
<?php
/**
* @package MokoJoomBackup
* @subpackage com_mokojoombackup
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @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;
namespace Joomla\Component\MokoSuiteBackup\Administrator\Model;
defined('_JEXEC') or die;
@@ -20,7 +20,7 @@ class BackupModel extends AdminModel
public function getForm($data = [], $loadData = true)
{
$form = $this->loadForm(
'com_mokojoombackup.backup',
'com_mokosuitebackup.backup',
'backup',
['control' => 'jform', 'load_data' => $loadData]
);
@@ -30,7 +30,7 @@ class BackupModel extends AdminModel
protected function loadFormData(): object
{
$data = Factory::getApplication()->getUserState('com_mokojoombackup.edit.backup.data', []);
$data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.backup.data', []);
if (empty($data)) {
$data = $this->getItem();

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