Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95317fb707 | |||
| cb5ff2843d | |||
| 4e6369094b | |||
| 0fbcc861d9 | |||
| 8cea58d1f6 | |||
| 84511b08d2 | |||
| 899a33bc58 | |||
| 7970597fb8 | |||
| 13f1c1db5e | |||
| 7ea30aa146 | |||
| d96f3e7760 | |||
| 10b31fea84 | |||
| 997924a107 | |||
| 9319abec41 | |||
| 7e404b0246 | |||
| 6638577cf5 | |||
| 114995242d | |||
| 3d6c0974fa | |||
| 8aefc1d702 | |||
| da52a9d2f9 | |||
| 0dc0eb1bef | |||
| 1def73df19 | |||
| 48f132ecf9 | |||
| c17349277d | |||
| 5a6ad02b53 | |||
| 29da9776cd | |||
| 09bac755a9 | |||
| f830dc2ddf | |||
| 5698c074da | |||
| aaf189b87a | |||
| 61023821e6 | |||
| 02a6e30db1 | |||
| 5a0cd51df6 | |||
| 12c832d7fe | |||
| 65c8820db4 | |||
| 0f914c3061 | |||
| 4191f44c1b | |||
| fb99afbeba | |||
| de632e9c5c |
@@ -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: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# 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 mokocli 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/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/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: mokocli.Release
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
|
||||
# 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 mokocli 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/mokocli/cli" ]; then
|
||||
echo "MOKO_CLI=/opt/mokocli/cli" >> "$GITHUB_ENV"
|
||||
else
|
||||
git clone --depth 1 --branch main --quiet \
|
||||
"https://x-access-token:${{ secrets.MOKOGITEA_TOKEN }}@git.mokoconsulting.tech/MokoConsulting/mokocli.git" \
|
||||
/tmp/mokocli
|
||||
cd /tmp/mokocli && composer install --no-dev --no-interaction --quiet
|
||||
echo "MOKO_CLI=/tmp/mokocli/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"
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: "Publish to Composer"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '[0-9]*.[0-9]*.[0-9]*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Package
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
!contains(github.event.head_commit.message, '[skip ci]') &&
|
||||
!contains(github.event.head_commit.message, '[skip publish]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
run: |
|
||||
if ! command -v php &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-dev --no-interaction --prefer-dist --quiet
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
# Gitea Composer Registry — auto-publishes from tags
|
||||
# The tag push itself registers the package at:
|
||||
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
|
||||
- name: Verify Gitea registry
|
||||
run: |
|
||||
echo "Gitea Composer registry auto-publishes from tags."
|
||||
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
|
||||
echo "Install: composer require mokoconsulting/mokocli"
|
||||
|
||||
# Packagist — notify of new version
|
||||
- name: Notify Packagist
|
||||
if: secrets.PACKAGIST_TOKEN != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Notifying Packagist of version ${VERSION}..."
|
||||
curl -sf -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
|
||||
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
|
||||
&& echo "Packagist notified" \
|
||||
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.38.03
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+534
-534
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,82 +0,0 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
|
||||
# PATH: /.gitea/workflows/security-audit.yml
|
||||
# VERSION: 01.00.00
|
||||
# BRIEF: Dependency vulnerability scanning for composer and npm packages
|
||||
|
||||
name: "Universal: Security Audit"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'composer.json'
|
||||
- 'composer.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
|
||||
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Composer audit
|
||||
if: hashFiles('composer.lock') != ''
|
||||
run: |
|
||||
echo "=== Composer Security Audit ==="
|
||||
if ! command -v composer &> /dev/null; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
|
||||
fi
|
||||
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
|
||||
RESULT=$?
|
||||
if [ $RESULT -ne 0 ]; then
|
||||
echo "::warning::Composer vulnerabilities found"
|
||||
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "No known vulnerabilities in composer dependencies"
|
||||
fi
|
||||
|
||||
- name: NPM audit
|
||||
if: hashFiles('package-lock.json') != ''
|
||||
run: |
|
||||
echo "=== NPM Security Audit ==="
|
||||
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
|
||||
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
|
||||
echo "No known vulnerabilities in npm dependencies"
|
||||
else
|
||||
echo "::warning::NPM vulnerabilities found"
|
||||
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Notify on vulnerabilities
|
||||
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
|
||||
run: |
|
||||
REPO="${{ github.event.repository.name }}"
|
||||
curl -sS \
|
||||
-H "Title: ${REPO} has vulnerable dependencies" \
|
||||
-H "Tags: lock,warning" \
|
||||
-H "Priority: high" \
|
||||
-d "Security audit found vulnerabilities. Review dependency updates." \
|
||||
"${NTFY_URL}/${NTFY_TOPIC}" || true
|
||||
+22
-6
@@ -1,14 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.38.03] --- 2026-06-23
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
## [01.38.03] --- 2026-06-23
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
## [01.38.02] --- 2026-06-23
|
||||
### Added
|
||||
- MokoRestore: post-restore reset options — passwords, hits, versions, sessions, cache (#131)
|
||||
- MokoRestore: per-table conflict resolution — replace, skip, merge, data-only per table (#132)
|
||||
- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users"
|
||||
- MokoRestore: auto-detect sanitized passwords and prompt for reset
|
||||
- Data sanitization: passwords, emails, sessions in backup profile settings (#129)
|
||||
- Manual purge: delete all backups older than a selected date with count preview (#119)
|
||||
- CPanel admin dashboard module with backup status, quick actions, and profile buttons (#105)
|
||||
- 7z archive format via system 7za/7z binary with optional password encryption (#122)
|
||||
- SFTP remote file browser: browse remote server directories to select backup path (#98)
|
||||
|
||||
## [01.38.02] --- 2026-06-23
|
||||
### Fixed
|
||||
- MokoRestore: data-only mode now uses REPLACE INTO to handle existing rows
|
||||
- MokoRestore: temporary password is now randomly generated (not hardcoded "changeme")
|
||||
|
||||
## [01.38.01] --- 2026-06-23
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.38.01] --- 2026-06-23
|
||||
## [01.38.05] --- 2026-06-23
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
## [01.38.04] --- 2026-06-23
|
||||
|
||||
@@ -1,50 +1,80 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.38.03 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
## Overview
|
||||
|
||||
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.
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Package** | `pkg_mokosuitebackup` |
|
||||
| **Type** | Joomla Package (8 sub-extensions) |
|
||||
| **Joomla** | 6.x+ |
|
||||
| **PHP** | 8.1+ |
|
||||
| **License** | GPL-3.0-or-later |
|
||||
|
||||
## Features
|
||||
|
||||
- Full site backup (database + files + configuration)
|
||||
- Database-only backup mode
|
||||
- Files-only backup mode
|
||||
- Multiple backup profiles with independent configurations
|
||||
- File and directory exclusion filters
|
||||
- Table exclusion filters for database backups
|
||||
- Step-based backup engine (avoids PHP timeout on large sites)
|
||||
- CLI script for cron/scheduled backups
|
||||
- REST API (Joomla Web Services) for remote management
|
||||
- Backup record management (list, download, delete)
|
||||
- Automatic old backup cleanup (configurable retention)
|
||||
- Admin dashboard with backup history and storage usage
|
||||
### Backup
|
||||
- Full site, database-only, files-only, and differential backup modes
|
||||
- Pre-flight validation — checks directory, disk space, extensions, credentials before starting
|
||||
- Auto-verify archive integrity after creation
|
||||
- Stepped AJAX engine prevents timeout on shared hosting
|
||||
- AES-256 ZIP encryption with configurable password
|
||||
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
|
||||
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
|
||||
|
||||
### Content Snapshots
|
||||
- Lightweight JSON snapshots of articles, categories, and modules
|
||||
- Includes tags, custom fields, workflow associations
|
||||
- Restore modes: Replace (clean slate) or Merge (upsert)
|
||||
- Selective article restore — browse and pick individual items
|
||||
- Automatic retention (max count + max age)
|
||||
- Scheduled snapshot task via com_scheduler
|
||||
|
||||
### Remote Storage
|
||||
- SFTP with SSH key file authentication (key stored base64-encoded in database)
|
||||
- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO)
|
||||
- Google Drive with OAuth2 and resumable uploads
|
||||
- Graceful degradation — local backup preserved if upload fails
|
||||
|
||||
### MokoRestore Standalone Wizard
|
||||
- 9-step restore wizard that works without Joomla installed
|
||||
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
|
||||
- Post-restore actions: reset passwords, hits, versions, sessions, cache
|
||||
- Auto-detect sanitized passwords and prompt for reset
|
||||
- Standalone mode: restore.php scans directory for ZIP files
|
||||
- Wrapped mode: restore.php bundled inside backup ZIP
|
||||
- Security gate with filesystem verification
|
||||
|
||||
### Notifications
|
||||
- Email on success/failure per profile
|
||||
- ntfy push notifications
|
||||
- Notifications for restore and snapshot operations
|
||||
|
||||
### Admin Dashboard
|
||||
- Last backup status, next scheduled, total count, storage used
|
||||
- Snapshot widget with latest info and type badges
|
||||
- 30-day backup trend chart
|
||||
- Per-profile storage breakdown
|
||||
- System health checks
|
||||
|
||||
### CLI
|
||||
- `mokosuitebackup:run --profile=1` — run backup
|
||||
- `mokosuitebackup:restore 1 --files-only --db-only --password=xxx`
|
||||
- `mokosuitebackup:snapshot create|restore|list|delete`
|
||||
|
||||
### REST API
|
||||
- Backup: start, list, download, delete, profiles
|
||||
- Snapshots: create, list, restore, delete, download
|
||||
- Profile credentials masked in API responses
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
||||
1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases)
|
||||
2. Joomla Administrator > Extensions > Install
|
||||
3. System plugin enabled automatically on install
|
||||
3. Components > MokoSuiteBackup > Dashboard
|
||||
|
||||
## Configuration
|
||||
## Documentation
|
||||
|
||||
- **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
|
||||
|
||||
## REST API
|
||||
|
||||
The webservices plugin exposes endpoints compatible with the MokoBackup MCP server:
|
||||
|
||||
- `POST /api/index.php/v1/mokobackup/backup` — Start a backup
|
||||
- `GET /api/index.php/v1/mokobackup/backups` — List backup records
|
||||
- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive
|
||||
- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record
|
||||
- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles
|
||||
See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!DOCTYPE html><title></title>
|
||||
@@ -40,6 +40,7 @@
|
||||
>
|
||||
<option value="zip">ZIP</option>
|
||||
<option value="tar.gz">tar.gz</option>
|
||||
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
|
||||
</field>
|
||||
<field
|
||||
name="compression_level"
|
||||
@@ -75,10 +76,10 @@
|
||||
type="PlaceholderText"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
default="[host]_[datetime]_profile[profile_id]"
|
||||
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||
maxlength="512"
|
||||
hint="[host]_[datetime]_profile[profile_id]"
|
||||
placeholders="[host],[datetime],[date],[time],[year],[month],[day],[hour],[minute],[second],[profile_id],[profile_name],[site_name],[type],[random]"
|
||||
hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||
placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
@@ -101,6 +102,54 @@
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||
<field
|
||||
name="sanitize_passwords"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="preserve_super_admin"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
showon="sanitize_passwords:1"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_emails"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="sanitize_sessions"
|
||||
type="radio"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||
default="1"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
|
||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||
<field
|
||||
name="id"
|
||||
@@ -243,12 +292,13 @@
|
||||
/>
|
||||
<field
|
||||
name="sftp_path"
|
||||
type="text"
|
||||
type="SftpPath"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC"
|
||||
default="/backups"
|
||||
maxlength="512"
|
||||
showon="remote_storage:sftp"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
|
||||
; Archive settings
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
|
||||
COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)"
|
||||
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level"
|
||||
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
||||
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
|
||||
@@ -130,15 +131,26 @@ 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: [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_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_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="MokoRestore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||
|
||||
; Data Sanitization
|
||||
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
|
||||
@@ -438,6 +450,19 @@ COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
|
||||
|
||||
; Purge
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
|
||||
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
|
||||
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
|
||||
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
|
||||
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -103,3 +103,16 @@ COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
||||
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
||||
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above."
|
||||
|
||||
; Purge
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_PURGE="Purge Old Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_TITLE="Purge Old Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_DESC="Delete all completed backup records older than the selected date. This permanently removes archive files, log files, and database records."
|
||||
COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL="Delete all backups before this date"
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUBMIT="Purge Backups"
|
||||
COM_MOKOJOOMBACKUP_PURGE_CONFIRM="Are you sure? This action cannot be undone."
|
||||
COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG="This will permanently delete %d backup(s) and their archive files."
|
||||
COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND="No completed backups found before the selected date."
|
||||
COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE="Invalid date. Please select a valid date."
|
||||
COM_MOKOJOOMBACKUP_PURGE_SUCCESS="%d backup(s) purged successfully."
|
||||
COM_MOKOJOOMBACKUP_PURGE_PARTIAL="%d backup(s) purged, but %d could not be deleted."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`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 '[DEFAULT_DIR]',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
|
||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
||||
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
||||
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
||||
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`;
|
||||
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,34 @@
|
||||
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`archive_name_format`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[datetime]', '[DATETIME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[time]', '[TIME]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[hour]', '[HOUR]'),
|
||||
'[minute]', '[MINUTE]'),
|
||||
'[second]', '[SECOND]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]'),
|
||||
'[type]', '[TYPE]'),
|
||||
'[random]', '[RANDOM]')
|
||||
WHERE `archive_name_format` REGEXP '\\[[a-z]';
|
||||
|
||||
UPDATE `#__mokosuitebackup_profiles` SET
|
||||
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||
`backup_dir`,
|
||||
'[host]', '[HOST]'),
|
||||
'[site_name]', '[SITE_NAME]'),
|
||||
'[date]', '[DATE]'),
|
||||
'[year]', '[YEAR]'),
|
||||
'[month]', '[MONTH]'),
|
||||
'[day]', '[DAY]'),
|
||||
'[profile_id]', '[PROFILE_ID]'),
|
||||
'[profile_name]', '[PROFILE_NAME]')
|
||||
WHERE `backup_dir` REGEXP '\\[[a-z]';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||
@@ -286,7 +286,7 @@ class AjaxController extends BaseController
|
||||
}
|
||||
|
||||
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
|
||||
and name-level ([site_name], [host], [profile_id], etc.) */
|
||||
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if ($profileId > 0) {
|
||||
@@ -713,6 +713,57 @@ class AjaxController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count backup records that would be purged before a given date.
|
||||
* POST: task=ajax.countPurge&date=2025-01-01
|
||||
*/
|
||||
public function countPurge(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$date = $this->input->getString('date', '');
|
||||
|
||||
if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = $date . ' 00:00:00';
|
||||
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
$db->setQuery($query);
|
||||
$count = (int) $db->loadResult();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Database error'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'count' => $count,
|
||||
'date' => $date,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two backup records side-by-side.
|
||||
* POST: task=ajax.compareBackups&id1=123&id2=456
|
||||
@@ -828,6 +879,184 @@ class AjaxController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse directories on a remote SFTP server for the path picker.
|
||||
* POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path
|
||||
*/
|
||||
public function browseSftpDir(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$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;
|
||||
}
|
||||
|
||||
$profileId = $this->input->getInt('profile_id', 0);
|
||||
|
||||
if (!$profileId) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing profile_id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/* Load the profile to get SFTP credentials */
|
||||
try {
|
||||
$db = Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('id') . ' = ' . $profileId);
|
||||
$db->setQuery($query);
|
||||
$profile = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$profile) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Profile not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$host = $profile->sftp_host ?? '';
|
||||
$port = (int) ($profile->sftp_port ?? 22);
|
||||
$username = $profile->sftp_username ?? '';
|
||||
$keyData = $profile->sftp_key_data ?? '';
|
||||
$password = $profile->sftp_password ?? '';
|
||||
|
||||
if (empty($host) || empty($username)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($keyData) && empty($password)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$requestPath = $this->input->getString('path', '/');
|
||||
|
||||
/* Sanitize: must start with / and not contain shell meta-characters */
|
||||
$requestPath = '/' . ltrim($requestPath, '/');
|
||||
|
||||
if (preg_match('/[;&|`$<>]/', $requestPath)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid path characters']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$keyFile = null;
|
||||
|
||||
try {
|
||||
/* Write temp key if using key auth (same pattern as SftpUploader) */
|
||||
if (!empty($keyData)) {
|
||||
$keyContent = base64_decode($keyData, true);
|
||||
|
||||
if ($keyContent === false) {
|
||||
$keyContent = $keyData;
|
||||
}
|
||||
|
||||
$keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key';
|
||||
|
||||
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||
}
|
||||
|
||||
chmod($keyFile, 0600);
|
||||
}
|
||||
|
||||
/* Build SSH command to list directories */
|
||||
$escapedPath = escapeshellarg($requestPath);
|
||||
$remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"';
|
||||
|
||||
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10'];
|
||||
|
||||
if ($port !== 22) {
|
||||
$parts[] = '-p';
|
||||
$parts[] = (string) $port;
|
||||
}
|
||||
|
||||
if ($keyFile !== null) {
|
||||
$parts[] = '-i';
|
||||
$parts[] = escapeshellarg($keyFile);
|
||||
}
|
||||
|
||||
$parts[] = escapeshellarg($username . '@' . $host);
|
||||
$parts[] = escapeshellarg($remoteCmd);
|
||||
|
||||
$cmd = implode(' ', $parts);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||
|
||||
/* exitCode 1 from grep means no matches (empty dir), which is OK */
|
||||
if ($exitCode !== 0 && $exitCode !== 1) {
|
||||
throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output));
|
||||
}
|
||||
|
||||
/* Parse output: each line is a directory name ending with / */
|
||||
$dirs = [];
|
||||
|
||||
foreach ($output as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if ($line === '' || $line === './' || $line === '../') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dirName = rtrim($line, '/');
|
||||
|
||||
if ($dirName === '' || $dirName === '.' || $dirName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullPath = rtrim($requestPath, '/') . '/' . $dirName;
|
||||
|
||||
$dirs[] = [
|
||||
'name' => $dirName,
|
||||
'path' => $fullPath,
|
||||
];
|
||||
}
|
||||
|
||||
usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
|
||||
/* Parent path */
|
||||
$parent = null;
|
||||
|
||||
if ($requestPath !== '/') {
|
||||
$parent = \dirname($requestPath);
|
||||
|
||||
if ($parent === '') {
|
||||
$parent = '/';
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'current' => $requestPath,
|
||||
'parent' => $parent,
|
||||
'dirs' => $dirs,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]);
|
||||
} finally {
|
||||
if ($keyFile !== null && is_file($keyFile)) {
|
||||
unlink($keyFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
@@ -165,6 +165,88 @@ class BackupsController extends AdminController
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge (delete) all completed backup records older than a given date.
|
||||
*
|
||||
* Deletes archive files, log files, and database records.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function purge(): void
|
||||
{
|
||||
$this->checkToken();
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoffDate = $this->input->getString('purge_date', '');
|
||||
|
||||
if (empty($cutoffDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $cutoffDate)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_INVALID_DATE'), 'error');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = $cutoffDate . ' 00:00:00';
|
||||
|
||||
$db = $this->app->getContainer()->get('DatabaseDriver');
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('id'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff))
|
||||
->where($db->quoteName('status') . ' = ' . $db->quote('complete'));
|
||||
$db->setQuery($query);
|
||||
$ids = $db->loadColumn();
|
||||
|
||||
if (empty($ids)) {
|
||||
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'), 'warning');
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->getModel('Backup')->getTable();
|
||||
$deleted = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if ($table->load((int) $id)) {
|
||||
if ($table->delete()) {
|
||||
$deleted++;
|
||||
} else {
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$table->reset();
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_PARTIAL', $deleted, $errors), 'warning');
|
||||
} else {
|
||||
$this->setMessage(Text::sprintf('COM_MOKOJOOMBACKUP_PURGE_SUCCESS', $deleted));
|
||||
}
|
||||
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op target for the purge toolbar button.
|
||||
*
|
||||
* The toolbar button needs a task so Joomla does not complain,
|
||||
* but the actual purge is triggered via the modal form which
|
||||
* submits to backups.purge. This method simply redirects back.
|
||||
*/
|
||||
public function purgeModal(): void
|
||||
{
|
||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify integrity of a backup archive by re-computing SHA-256.
|
||||
*/
|
||||
|
||||
@@ -87,8 +87,14 @@ class BackupEngine
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$archiveName = '';
|
||||
$archiver = $this->createArchiver($archiveFormat);
|
||||
|
||||
// Pass encryption password to 7z archiver (handles it natively via -p flag)
|
||||
if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) {
|
||||
$archiver->setEncryptionPassword($profile->encryption_password);
|
||||
}
|
||||
|
||||
$archiveExt = $archiver->getExtension();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||
|
||||
if (empty($description)) {
|
||||
@@ -137,7 +143,19 @@ class BackupEngine
|
||||
if ($profile->backup_type !== 'files') {
|
||||
$this->log('Starting database dump...');
|
||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||
$dumper = new DatabaseDumper($excludeTables);
|
||||
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
||||
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
||||
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
||||
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
||||
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
||||
|
||||
if ($sanitizePasswords) {
|
||||
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
||||
}
|
||||
|
||||
if ($sanitizeEmails) {
|
||||
$this->log('User emails will be sanitized');
|
||||
}
|
||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||
$tablesCount = $dumper->getTablesCount();
|
||||
@@ -216,12 +234,14 @@ class BackupEngine
|
||||
$encryptionPassword = $profile->encryption_password ?? '';
|
||||
|
||||
if (!empty($encryptionPassword)) {
|
||||
if ($archiveFormat !== 'zip') {
|
||||
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
|
||||
} else {
|
||||
if ($archiveFormat === 'zip') {
|
||||
$this->log('Encrypting archive with AES-256...');
|
||||
$this->encryptArchive($archivePath, $encryptionPassword);
|
||||
$this->log('Archive encrypted');
|
||||
} elseif ($archiveFormat === '7z') {
|
||||
$this->log('Archive encrypted with AES-256 (7z native encryption)');
|
||||
} else {
|
||||
$this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +334,7 @@ class BackupEngine
|
||||
|
||||
// Write log file alongside the archive
|
||||
$logContent = implode("\n", $this->log);
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
||||
$logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
|
||||
if (@file_put_contents($logPath, $logContent) === false) {
|
||||
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
||||
}
|
||||
@@ -460,6 +480,7 @@ class BackupEngine
|
||||
return match ($format) {
|
||||
'zip' => new ZipArchiver(),
|
||||
'tar.gz' => new TarGzArchiver(),
|
||||
'7z' => new SevenZipArchiver(),
|
||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||
};
|
||||
}
|
||||
@@ -565,6 +586,13 @@ class BackupEngine
|
||||
return;
|
||||
}
|
||||
|
||||
// 7z verification via CLI
|
||||
if ($extension === '7z') {
|
||||
$this->verify7zArchive($archivePath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP verification
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
@@ -626,6 +654,64 @@ class BackupEngine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 7z archive using the CLI binary.
|
||||
*
|
||||
* @param string $archivePath Absolute path to the .7z file
|
||||
*
|
||||
* @throws \RuntimeException If the archive fails verification
|
||||
*/
|
||||
private function verify7zArchive(string $archivePath): void
|
||||
{
|
||||
// Test the archive with 7z t (test integrity)
|
||||
$candidates = PHP_OS_FAMILY === 'Windows'
|
||||
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
||||
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
||||
|
||||
$binary = null;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
||||
if (is_file($candidate) && is_executable($candidate)) {
|
||||
$binary = $candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
||||
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
||||
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
||||
|
||||
$result = trim((string) shell_exec($whichCmd));
|
||||
|
||||
if ($result !== '' && is_executable($result)) {
|
||||
$binary = $result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($binary === null) {
|
||||
// Cannot verify without the binary — log warning but don't fail
|
||||
$this->log('WARNING: Cannot verify 7z archive (7z binary not found for test)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cmd = escapeshellarg($binary) . ' t ' . escapeshellarg($archivePath) . ' -y 2>&1';
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new \RuntimeException(
|
||||
'Archive integrity check failed: 7z test exited with code ' . $exitCode
|
||||
. ': ' . implode("\n", array_slice($output, -5))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
|
||||
@@ -27,12 +27,35 @@ class DatabaseDumper
|
||||
|
||||
private int $tablesCount = 0;
|
||||
|
||||
/** @var bool Whether to sanitize user passwords */
|
||||
private bool $sanitizePasswords = false;
|
||||
|
||||
/** @var bool Whether to preserve super admin password when sanitizing */
|
||||
private bool $preserveSuperAdmin = false;
|
||||
|
||||
/** @var bool Whether to sanitize user emails */
|
||||
private bool $sanitizeEmails = false;
|
||||
|
||||
/** @var bool Whether to clear session data */
|
||||
private bool $sanitizeSessions = false;
|
||||
|
||||
/** Known invalid bcrypt hash used for sanitized passwords */
|
||||
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||
|
||||
/**
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* Supports suffixes: :data-only, :structure-only.
|
||||
* No suffix = exclude both (backward compatible).
|
||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||
* @param bool $sanitizePasswords Replace user password hashes with invalid value
|
||||
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
|
||||
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
|
||||
* @param bool $sanitizeSessions Skip session table data entirely
|
||||
*/
|
||||
public function __construct(array $excludeTables = [])
|
||||
public function __construct(
|
||||
array $excludeTables = [],
|
||||
bool $sanitizePasswords = false,
|
||||
bool $preserveSuperAdmin = false,
|
||||
bool $sanitizeEmails = false,
|
||||
bool $sanitizeSessions = false
|
||||
)
|
||||
{
|
||||
foreach ($excludeTables as $entry) {
|
||||
if (str_ends_with($entry, ':data-only')) {
|
||||
@@ -43,6 +66,16 @@ class DatabaseDumper
|
||||
$this->excludeBoth[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sanitizePasswords = $sanitizePasswords;
|
||||
$this->preserveSuperAdmin = $preserveSuperAdmin;
|
||||
$this->sanitizeEmails = $sanitizeEmails;
|
||||
$this->sanitizeSessions = $sanitizeSessions;
|
||||
|
||||
/* If session sanitization is on, auto-exclude session table data */
|
||||
if ($sanitizeSessions) {
|
||||
$this->excludeDataOnly[] = '#__session';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +187,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
@@ -326,6 +360,7 @@ class DatabaseDumper
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->sanitizeRow($row, $abstractName, $db);
|
||||
$values = [];
|
||||
|
||||
foreach ($row as $value) {
|
||||
@@ -351,6 +386,86 @@ class DatabaseDumper
|
||||
return filesize($filePath) ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a row if it belongs to the users table and sanitization is enabled.
|
||||
*
|
||||
* Replaces the password column with an invalid hash so the backup
|
||||
* cannot be used to extract user credentials.
|
||||
*/
|
||||
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
|
||||
{
|
||||
if ($abstractTable !== '#__users') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
|
||||
$userId = (int) $row['id'];
|
||||
|
||||
/* Preserve super admin emails if preserving super admin */
|
||||
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
|
||||
$row['email'] = 'user' . $userId . '@sanitized.example.com';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->sanitizePasswords || !isset($row['password'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->preserveSuperAdmin && isset($row['id'])) {
|
||||
if ($this->isSuperAdmin((int) $row['id'], $db)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$row['password'] = self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user ID belongs to the Super Users group (group_id = 8).
|
||||
*/
|
||||
private function isSuperAdmin(int $userId, object $db): bool
|
||||
{
|
||||
static $superAdminIds = null;
|
||||
|
||||
if ($superAdminIds === null) {
|
||||
$prefix = $db->getPrefix();
|
||||
|
||||
try {
|
||||
$db->setQuery(
|
||||
$db->getQuery(true)
|
||||
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||
->from($db->quoteName($prefix . 'user_usergroup_map'))
|
||||
->where($db->quoteName('group_id') . ' = 8')
|
||||
);
|
||||
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
|
||||
} catch (\Throwable $e) {
|
||||
$superAdminIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
return in_array($userId, $superAdminIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if passwords were sanitized (for use by callers to log the action).
|
||||
*/
|
||||
public function isPasswordSanitizationEnabled(): bool
|
||||
{
|
||||
return $this->sanitizePasswords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sentinel hash used for sanitized passwords.
|
||||
*/
|
||||
public static function getSanitizedHash(): string
|
||||
{
|
||||
return self::SANITIZED_HASH;
|
||||
}
|
||||
|
||||
public function getTablesCount(): int
|
||||
{
|
||||
return $this->tablesCount;
|
||||
|
||||
@@ -376,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
||||
function handleAction(string $action, array $data): array
|
||||
{
|
||||
return match ($action) {
|
||||
'preflight' => actionPreflight(),
|
||||
'extract' => actionExtract($data),
|
||||
'testdb' => actionTestDb($data),
|
||||
'database' => actionDatabase($data),
|
||||
'config' => actionConfig($data),
|
||||
'listAdmins' => actionListAdmins($data),
|
||||
'resetAdmin' => actionResetAdmin($data),
|
||||
'provision' => actionProvision($data),
|
||||
'cleanup' => actionCleanup(),
|
||||
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
|
||||
'preflight' => actionPreflight(),
|
||||
'extract' => actionExtract($data),
|
||||
'scanTables' => actionScanTables(),
|
||||
'testdb' => actionTestDb($data),
|
||||
'database' => actionDatabase($data),
|
||||
'config' => actionConfig($data),
|
||||
'listAdmins' => actionListAdmins($data),
|
||||
'resetAdmin' => actionResetAdmin($data),
|
||||
'postRestore' => actionPostRestore($data),
|
||||
'detectSanitized' => detectSanitizedPasswords($data),
|
||||
'provision' => actionProvision($data),
|
||||
'cleanup' => actionCleanup(),
|
||||
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -551,6 +554,65 @@ function actionExtract(array $data): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse database.sql and extract the list of table names.
|
||||
* Returns table names using the abstract #__ prefix so the UI
|
||||
* can display them before the user's target prefix is known.
|
||||
*/
|
||||
function actionScanTables(): array
|
||||
{
|
||||
$sqlFile = RESTORE_DIR . '/database.sql';
|
||||
|
||||
if (!is_file($sqlFile)) {
|
||||
return ['success' => true, 'tables' => [], 'message' => 'No database.sql found'];
|
||||
}
|
||||
|
||||
$sql = file_get_contents($sqlFile);
|
||||
$tables = [];
|
||||
|
||||
// Match DROP TABLE IF EXISTS `#__tablename` or CREATE TABLE ... `#__tablename`
|
||||
if (preg_match_all('/(?:DROP\s+TABLE\s+IF\s+EXISTS|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?)\s+`([^`]+)`/i', $sql, $matches)) {
|
||||
foreach ($matches[1] as $name) {
|
||||
if (!in_array($name, $tables, true)) {
|
||||
$tables[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically for easier scanning
|
||||
sort($tables, SORT_STRING);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'tables' => $tables,
|
||||
'count' => count($tables),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which table a SQL statement belongs to.
|
||||
* Returns the table name (with the prefix already applied) or empty string.
|
||||
*/
|
||||
function getStatementTable(string $stmt): string
|
||||
{
|
||||
// DROP TABLE IF EXISTS `prefix_tablename`
|
||||
if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([^`]+)`/i', $stmt, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// CREATE TABLE `prefix_tablename` or CREATE TABLE IF NOT EXISTS `prefix_tablename`
|
||||
if (preg_match('/^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`([^`]+)`/i', $stmt, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
// INSERT INTO `prefix_tablename`
|
||||
if (preg_match('/^INSERT\s+INTO\s+`([^`]+)`/i', $stmt, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function actionTestDb(array $data): array
|
||||
{
|
||||
$host = $data['db_host'] ?? 'localhost';
|
||||
@@ -608,10 +670,27 @@ function actionDatabase(array $data): array
|
||||
// Replace abstract #__ prefix with the user's target prefix
|
||||
$sql = str_replace('#__', $prefix, $sql);
|
||||
|
||||
// Decode per-table conflict resolution selections
|
||||
// Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly
|
||||
$tableResolutions = [];
|
||||
|
||||
if (!empty($data['table_resolutions'])) {
|
||||
$decoded = json_decode($data['table_resolutions'], true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
// Remap from abstract #__ names to the real prefix
|
||||
foreach ($decoded as $abstractName => $mode) {
|
||||
$realName = str_replace('#__', $prefix, $abstractName);
|
||||
$tableResolutions[$realName] = $mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$parts = explode(";\n", $sql);
|
||||
$statements = 0;
|
||||
$errors = 0;
|
||||
$errorList = [];
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$part = trim($part);
|
||||
@@ -620,6 +699,42 @@ function actionDatabase(array $data): array
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine which table this statement belongs to
|
||||
$table = getStatementTable($part);
|
||||
$mode = $tableResolutions[$table] ?? 'replace';
|
||||
|
||||
// Apply conflict resolution per table
|
||||
if ($mode === 'skip') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part);
|
||||
$isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part);
|
||||
$isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part);
|
||||
|
||||
if ($mode === 'merge') {
|
||||
// Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO
|
||||
if ($isDrop || $isCreate) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isInsert) {
|
||||
$part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part);
|
||||
}
|
||||
} elseif ($mode === 'dataonly') {
|
||||
/* Skip DROP and CREATE; use REPLACE INTO for data (overwrites on duplicate key) */
|
||||
if ($isDrop || $isCreate) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if ($isInsert) {
|
||||
$part = preg_replace('/^INSERT\s+INTO/i', 'REPLACE INTO', $part);
|
||||
}
|
||||
}
|
||||
// mode === 'replace' => execute everything as-is (default)
|
||||
|
||||
try {
|
||||
$pdo->exec($part);
|
||||
$statements++;
|
||||
@@ -634,11 +749,22 @@ function actionDatabase(array $data): array
|
||||
|
||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||
|
||||
$msg = "Executed {$statements} statements";
|
||||
|
||||
if ($skipped > 0) {
|
||||
$msg .= " ({$skipped} skipped)";
|
||||
}
|
||||
|
||||
if ($errors > 0) {
|
||||
$msg .= " ({$errors} warnings)";
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => ($statements > 0 || $errors === 0),
|
||||
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
|
||||
'message' => $msg,
|
||||
'statements' => $statements,
|
||||
'errors' => $errors,
|
||||
'skipped' => $skipped,
|
||||
'errorList' => $errorList,
|
||||
];
|
||||
}
|
||||
@@ -989,6 +1115,128 @@ function actionResetAdmin(array $data): array
|
||||
return ['success' => true, 'message' => 'Admin password updated successfully'];
|
||||
}
|
||||
|
||||
function actionPostRestore(array $data): array
|
||||
{
|
||||
$pdo = getDbConnection($data);
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$tasks = json_decode($data['tasks'] ?? '[]', true) ?: [];
|
||||
$results = [];
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
try {
|
||||
switch ($task) {
|
||||
case 'reset_passwords':
|
||||
/* Set all user passwords to a random temporary hash, block non-admin users */
|
||||
$tempPassword = bin2hex(random_bytes(8)); /* 16-char random hex */
|
||||
// clear activation tokens, and force password reset on next login.
|
||||
$tempHash = password_hash($tempPassword, PASSWORD_DEFAULT);
|
||||
$stmt = $pdo->prepare(
|
||||
"UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1"
|
||||
);
|
||||
$stmt->execute([$tempHash]);
|
||||
$affected = $stmt->rowCount();
|
||||
$results[] = "All {$affected} user password(s) reset to temporary password ({$tempPassword}) with forced reset";
|
||||
break;
|
||||
|
||||
case 'reset_hits':
|
||||
$pdo->exec("UPDATE {$prefix}content SET hits = 0");
|
||||
$results[] = 'Content hits reset to 0';
|
||||
break;
|
||||
|
||||
case 'clear_versions':
|
||||
try {
|
||||
$pdo->exec("TRUNCATE TABLE {$prefix}history");
|
||||
$results[] = 'Content version history cleared';
|
||||
} catch (PDOException $e) {
|
||||
$results[] = 'Version history: table not found (skipped)';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clear_sessions':
|
||||
$pdo->exec("TRUNCATE TABLE {$prefix}session");
|
||||
$results[] = 'Sessions cleared';
|
||||
break;
|
||||
|
||||
case 'clear_cache':
|
||||
// Clear Joomla cache tables
|
||||
foreach (['cache', 'cache_extension'] as $tbl) {
|
||||
try {
|
||||
$pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}");
|
||||
} catch (PDOException $e) {
|
||||
// Table may not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Delete files in cache/ directory
|
||||
$cacheDir = RESTORE_DIR . '/cache';
|
||||
$cacheCount = 0;
|
||||
|
||||
if (is_dir($cacheDir)) {
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($it as $item) {
|
||||
if ($item->isFile()) {
|
||||
@unlink($item->getPathname());
|
||||
$cacheCount++;
|
||||
} elseif ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear administrator/cache/
|
||||
$adminCacheDir = RESTORE_DIR . '/administrator/cache';
|
||||
|
||||
if (is_dir($adminCacheDir)) {
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($it as $item) {
|
||||
if ($item->isFile()) {
|
||||
@unlink($item->getPathname());
|
||||
$cacheCount++;
|
||||
} elseif ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed";
|
||||
break;
|
||||
|
||||
default:
|
||||
$results[] = "Unknown task: {$task}";
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$results[] = "Error ({$task}): " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the database contains sanitized sentinel password hashes.
|
||||
* Returns true if any user has the MokoSuiteBackup sanitized placeholder hash.
|
||||
*/
|
||||
function detectSanitizedPasswords(array $data): array
|
||||
{
|
||||
$pdo = getDbConnection($data);
|
||||
$prefix = getValidatedPrefix($data);
|
||||
$sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?");
|
||||
$stmt->execute([$sentinel]);
|
||||
$count = (int) $stmt->fetchColumn();
|
||||
|
||||
return ['success' => true, 'detected' => $count > 0, 'count' => $count];
|
||||
}
|
||||
|
||||
function actionProvision(array $data): array
|
||||
{
|
||||
$pdo = getDbConnection($data);
|
||||
@@ -1233,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
<div class="mr-steps" id="stepBar">
|
||||
<div class="mr-step active" data-step="1"><span class="mr-num">1</span>Checks</div>
|
||||
<div class="mr-step" data-step="2"><span class="mr-num">2</span>Extract</div>
|
||||
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Database</div>
|
||||
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</div>
|
||||
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Admin</div>
|
||||
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Provisioning</div>
|
||||
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
|
||||
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Tables</div>
|
||||
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Database</div>
|
||||
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Configuration</div>
|
||||
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Admin</div>
|
||||
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Post-Restore</div>
|
||||
<div class="mr-step" data-step="8"><span class="mr-num">8</span>Provisioning</div>
|
||||
<div class="mr-step" data-step="9"><span class="mr-num">9</span>Complete</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 0: Security Verification -->
|
||||
@@ -1292,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Database -->
|
||||
<!-- Step 3: Table Conflict Resolution -->
|
||||
<div class="mr-panel" id="panel3">
|
||||
<h2>Table Conflict Resolution</h2>
|
||||
<p class="mr-desc">Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.</p>
|
||||
<div style="margin-bottom:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('replace')">All Replace</button>
|
||||
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('skip')">All Skip</button>
|
||||
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('merge')">All Merge</button>
|
||||
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="presetExceptUsers()">Everything except users</button>
|
||||
</div>
|
||||
<div class="mr-alert mr-alert-info" style="font-size:0.85rem">
|
||||
<strong>Modes:</strong>
|
||||
<strong>Replace</strong> = drop + recreate + insert (default).
|
||||
<strong>Skip</strong> = ignore entirely.
|
||||
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
|
||||
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
|
||||
</div>
|
||||
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
|
||||
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
|
||||
</div>
|
||||
<input type="hidden" id="tableResolutions" value="{}">
|
||||
<div class="mr-status" id="tableScanStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnTablesContinue" onclick="goStep(4)">Continue to Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Database -->
|
||||
<div class="mr-panel" id="panel4">
|
||||
<h2>Database Configuration</h2>
|
||||
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
|
||||
<div class="mr-row">
|
||||
@@ -1312,13 +1590,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
<div class="mr-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
|
||||
<div class="mr-status" id="dbStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Site Configuration -->
|
||||
<div class="mr-panel" id="panel4">
|
||||
<!-- Step 5: Site Configuration -->
|
||||
<div class="mr-panel" id="panel5">
|
||||
<h2>Site Configuration</h2>
|
||||
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security — enter the correct values for this server.</p>
|
||||
|
||||
@@ -1361,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
</div>
|
||||
<div class="mr-status" id="configStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Admin Password Reset -->
|
||||
<div class="mr-panel" id="panel5">
|
||||
<!-- Step 6: Admin Password Reset -->
|
||||
<div class="mr-panel" id="panel6">
|
||||
<h2>Super Admin Password</h2>
|
||||
<p class="mr-desc">Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.</p>
|
||||
<div class="mr-field">
|
||||
@@ -1380,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
</div>
|
||||
<div class="mr-status" id="adminStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
|
||||
<div>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Skip</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Client Provisioning -->
|
||||
<div class="mr-panel" id="panel6">
|
||||
<!-- Step 7: Post-Restore Actions -->
|
||||
<div class="mr-panel" id="panel7">
|
||||
<h2>Post-Restore Actions</h2>
|
||||
<p class="mr-desc">Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.</p>
|
||||
<div class="mr-alert mr-alert-warn" id="postRestoreSanitizedWarn" style="display:none">
|
||||
<strong>Sanitized passwords detected!</strong> This backup contains placeholder password hashes that will prevent all users from logging in. The "Reset all user passwords" option below is strongly recommended.
|
||||
</div>
|
||||
<ul class="mr-provision-list" id="postRestoreList">
|
||||
<li><input type="checkbox" class="post-restore-task" id="prResetPasswords" value="reset_passwords"><span>Reset all user passwords</span><span class="mr-provision-desc">Set to random temporary password and force reset on next login</span></li>
|
||||
<li><input type="checkbox" class="post-restore-task" value="reset_hits"><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
|
||||
<li><input type="checkbox" class="post-restore-task" value="clear_versions"><span>Clear version history</span><span class="mr-provision-desc">Truncate the content version history table</span></li>
|
||||
<li><input type="checkbox" class="post-restore-task" value="clear_sessions" checked><span>Clear sessions</span><span class="mr-provision-desc">Remove all active user sessions</span></li>
|
||||
<li><input type="checkbox" class="post-restore-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate cache tables and delete cache files</span></li>
|
||||
</ul>
|
||||
<div class="mr-status" id="postRestoreStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Back</button>
|
||||
<div>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(8)">Skip</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnPostRestore" onclick="runPostRestore()">Run Selected Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 8: Client Provisioning -->
|
||||
<div class="mr-panel" id="panel8">
|
||||
<h2>Client Provisioning</h2>
|
||||
<p class="mr-desc">Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.</p>
|
||||
<ul class="mr-provision-list">
|
||||
@@ -1404,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
||||
</ul>
|
||||
<div class="mr-status" id="provisionStatus"></div>
|
||||
<div class="mr-actions">
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Back</button>
|
||||
<div>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
|
||||
<button class="mr-btn mr-btn-outline" onclick="goStep(9)">Skip</button>
|
||||
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 7: Complete -->
|
||||
<div class="mr-panel" id="panel7">
|
||||
<!-- Step 9: Complete -->
|
||||
<div class="mr-panel" id="panel9">
|
||||
<h2>Installation Complete</h2>
|
||||
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
|
||||
<div class="mr-alert mr-alert-success">
|
||||
@@ -1484,7 +1786,9 @@ function goStep(n) {
|
||||
else if (sn < n) s.classList.add('done');
|
||||
});
|
||||
|
||||
if (n === 5) loadAdmins();
|
||||
if (n === 3) scanTables();
|
||||
if (n === 6) loadAdmins();
|
||||
if (n === 7) checkSanitizedPasswords();
|
||||
}
|
||||
|
||||
function setStatus(id, msg, type) {
|
||||
@@ -1621,7 +1925,111 @@ async function runExtract() {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3
|
||||
// Step 3: Table Conflict Resolution
|
||||
let tableList = [];
|
||||
|
||||
async function scanTables() {
|
||||
const container = document.getElementById('tableResolutionList');
|
||||
|
||||
// Only scan once
|
||||
if (tableList.length > 0) return;
|
||||
|
||||
log('Scanning database.sql for table names...');
|
||||
const r = await post('scanTables');
|
||||
|
||||
if (!r.success || !r.tables || r.tables.length === 0) {
|
||||
container.innerHTML = '<div style="padding:1rem;color:#94a3b8;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
|
||||
setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error');
|
||||
log(r.message || 'No tables found');
|
||||
return;
|
||||
}
|
||||
|
||||
tableList = r.tables;
|
||||
log('Found ' + r.count + ' tables');
|
||||
setStatus('tableScanStatus', 'Found ' + r.count + ' tables', 'success');
|
||||
|
||||
renderTableList();
|
||||
}
|
||||
|
||||
function renderTableList() {
|
||||
const container = document.getElementById('tableResolutionList');
|
||||
container.innerHTML = '';
|
||||
|
||||
var resolutions = {};
|
||||
|
||||
tableList.forEach(function(name) {
|
||||
var row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;';
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;';
|
||||
label.textContent = name;
|
||||
|
||||
var sel = document.createElement('select');
|
||||
sel.dataset.table = name;
|
||||
sel.className = 'table-mode-select';
|
||||
sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;';
|
||||
|
||||
var modes = [
|
||||
['replace', 'Replace'],
|
||||
['skip', 'Skip'],
|
||||
['merge', 'Merge'],
|
||||
['dataonly', 'Data Only']
|
||||
];
|
||||
|
||||
modes.forEach(function(m) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = m[0];
|
||||
opt.textContent = m[1];
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
|
||||
sel.addEventListener('change', updateTableResolutions);
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(sel);
|
||||
container.appendChild(row);
|
||||
|
||||
resolutions[name] = 'replace';
|
||||
});
|
||||
|
||||
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
|
||||
}
|
||||
|
||||
function updateTableResolutions() {
|
||||
var resolutions = {};
|
||||
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
|
||||
resolutions[sel.dataset.table] = sel.value;
|
||||
});
|
||||
document.getElementById('tableResolutions').value = JSON.stringify(resolutions);
|
||||
}
|
||||
|
||||
function setAllTableMode(mode) {
|
||||
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
|
||||
sel.value = mode;
|
||||
});
|
||||
updateTableResolutions();
|
||||
log('Set all tables to: ' + mode);
|
||||
}
|
||||
|
||||
function presetExceptUsers() {
|
||||
var userTables = ['#__users', '#__user_usergroup_map', '#__user_profiles'];
|
||||
|
||||
document.querySelectorAll('.table-mode-select').forEach(function(sel) {
|
||||
var tableName = sel.dataset.table;
|
||||
|
||||
if (userTables.indexOf(tableName) !== -1) {
|
||||
sel.value = 'skip';
|
||||
} else {
|
||||
sel.value = 'replace';
|
||||
}
|
||||
});
|
||||
|
||||
updateTableResolutions();
|
||||
log('Preset: Replace all except user tables (skipped)');
|
||||
}
|
||||
|
||||
// Step 4
|
||||
function getDbParams() {
|
||||
return {
|
||||
db_host: document.getElementById('dbHost').value,
|
||||
@@ -1647,7 +2055,12 @@ async function runDatabase() {
|
||||
log('Importing database...');
|
||||
|
||||
dbConfig = getDbParams();
|
||||
const r = await post('database', dbConfig);
|
||||
// Include table conflict resolution selections
|
||||
var tableRes = document.getElementById('tableResolutions');
|
||||
var dbParams = Object.assign({}, dbConfig, {
|
||||
table_resolutions: tableRes ? tableRes.value : '{}'
|
||||
});
|
||||
const r = await post('database', dbParams);
|
||||
|
||||
document.getElementById('dbProgress').style.width = '100%';
|
||||
setBtnLoading(btn, false);
|
||||
@@ -1655,17 +2068,20 @@ async function runDatabase() {
|
||||
if (r.success) {
|
||||
setStatus('dbStatus', r.message, 'success');
|
||||
log(r.message);
|
||||
if (r.skipped && r.skipped > 0) {
|
||||
log(' Skipped ' + r.skipped + ' statements due to conflict resolution');
|
||||
}
|
||||
if (r.errorList && r.errorList.length > 0) {
|
||||
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
|
||||
}
|
||||
setTimeout(function() { goStep(4); }, 500);
|
||||
setTimeout(function() { goStep(5); }, 500);
|
||||
} else {
|
||||
setStatus('dbStatus', r.message, 'error');
|
||||
log('FAILED: ' + r.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4
|
||||
// Step 5
|
||||
async function runConfig() {
|
||||
const btn = document.getElementById('btnConfig');
|
||||
setBtnLoading(btn, true);
|
||||
@@ -1686,14 +2102,14 @@ async function runConfig() {
|
||||
if (r.success) {
|
||||
setStatus('configStatus', r.message, 'success');
|
||||
log(r.message);
|
||||
setTimeout(function() { goStep(5); }, 500);
|
||||
setTimeout(function() { goStep(6); }, 500);
|
||||
} else {
|
||||
setStatus('configStatus', r.message, 'error');
|
||||
log('FAILED: ' + r.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5
|
||||
// Step 6
|
||||
async function loadAdmins() {
|
||||
const sel = document.getElementById('adminSelect');
|
||||
while (sel.firstChild) sel.removeChild(sel.firstChild);
|
||||
@@ -1738,20 +2154,65 @@ async function runResetAdmin() {
|
||||
if (r.success) {
|
||||
setStatus('adminStatus', r.message, 'success');
|
||||
log(r.message);
|
||||
setTimeout(function() { goStep(6); }, 500);
|
||||
setTimeout(function() { goStep(7); }, 500);
|
||||
} else {
|
||||
setStatus('adminStatus', r.message, 'error');
|
||||
log('FAILED: ' + r.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6
|
||||
// Step 7: Post-Restore
|
||||
async function checkSanitizedPasswords() {
|
||||
log('Checking for sanitized password hashes...');
|
||||
|
||||
try {
|
||||
const r = await post('detectSanitized', dbConfig);
|
||||
|
||||
if (r.success && r.detected) {
|
||||
document.getElementById('postRestoreSanitizedWarn').style.display = '';
|
||||
document.getElementById('prResetPasswords').checked = true;
|
||||
log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords');
|
||||
} else {
|
||||
document.getElementById('postRestoreSanitizedWarn').style.display = 'none';
|
||||
log('No sanitized passwords detected');
|
||||
}
|
||||
} catch (e) {
|
||||
log('Could not check for sanitized passwords: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPostRestore() {
|
||||
const btn = document.getElementById('btnPostRestore');
|
||||
const tasks = [];
|
||||
document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); });
|
||||
|
||||
if (tasks.length === 0) { goStep(8); return; }
|
||||
|
||||
setBtnLoading(btn, true);
|
||||
log('Running ' + tasks.length + ' post-restore task(s)...');
|
||||
|
||||
const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) });
|
||||
const r = await post('postRestore', params);
|
||||
|
||||
setBtnLoading(btn, false);
|
||||
|
||||
if (r.success) {
|
||||
setStatus('postRestoreStatus', r.message, 'success');
|
||||
r.results.forEach(function(msg) { log(' ' + msg); });
|
||||
setTimeout(function() { goStep(8); }, 500);
|
||||
} else {
|
||||
setStatus('postRestoreStatus', r.message, 'error');
|
||||
log('FAILED: ' + r.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8
|
||||
async function runProvision() {
|
||||
const btn = document.getElementById('btnProvision');
|
||||
const tasks = [];
|
||||
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); });
|
||||
|
||||
if (tasks.length === 0) { goStep(7); return; }
|
||||
if (tasks.length === 0) { goStep(9); return; }
|
||||
|
||||
setBtnLoading(btn, true);
|
||||
log('Running ' + tasks.length + ' provisioning tasks...');
|
||||
@@ -1764,14 +2225,14 @@ async function runProvision() {
|
||||
if (r.success) {
|
||||
setStatus('provisionStatus', r.message, 'success');
|
||||
r.results.forEach(function(msg) { log(' ' + msg); });
|
||||
setTimeout(function() { goStep(7); }, 500);
|
||||
setTimeout(function() { goStep(9); }, 500);
|
||||
} else {
|
||||
setStatus('provisionStatus', r.message, 'error');
|
||||
log('FAILED: ' + r.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7
|
||||
// Step 9
|
||||
async function runCleanup() {
|
||||
log('Cleaning up restore files...');
|
||||
const r = await post('cleanup');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*
|
||||
* Resolves placeholders like [host], [date], [profile_name] in backup
|
||||
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
|
||||
* directory paths and archive filename formats.
|
||||
*/
|
||||
|
||||
@@ -24,21 +24,21 @@ class PlaceholderResolver
|
||||
* Supported placeholders and their descriptions (for documentation).
|
||||
*/
|
||||
public const PLACEHOLDERS = [
|
||||
'[host]' => 'Server hostname',
|
||||
'[date]' => 'Date as Ymd (e.g. 20260604)',
|
||||
'[time]' => 'Time as His (e.g. 143025)',
|
||||
'[datetime]' => 'Date and time as Ymd_His',
|
||||
'[year]' => 'Four-digit year',
|
||||
'[month]' => 'Two-digit month',
|
||||
'[day]' => 'Two-digit day',
|
||||
'[hour]' => 'Two-digit hour (24h)',
|
||||
'[minute]' => 'Two-digit minute',
|
||||
'[second]' => 'Two-digit second',
|
||||
'[profile_id]' => 'Backup profile ID',
|
||||
'[profile_name]' => 'Profile title (sanitized)',
|
||||
'[site_name]' => 'Joomla site name (sanitized)',
|
||||
'[type]' => 'Backup type (full, database, files, differential)',
|
||||
'[random]' => 'Random 6-character hex string',
|
||||
'[HOST]' => 'Server hostname',
|
||||
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
|
||||
'[TIME]' => 'Time as His (e.g. 143025)',
|
||||
'[DATETIME]' => 'Date and time as Ymd_His',
|
||||
'[YEAR]' => 'Four-digit year',
|
||||
'[MONTH]' => 'Two-digit month',
|
||||
'[DAY]' => 'Two-digit day',
|
||||
'[HOUR]' => 'Two-digit hour (24h)',
|
||||
'[MINUTE]' => 'Two-digit minute',
|
||||
'[SECOND]' => 'Two-digit second',
|
||||
'[PROFILE_ID]' => 'Backup profile ID',
|
||||
'[PROFILE_NAME]' => 'Profile title (sanitized)',
|
||||
'[SITE_NAME]' => 'Joomla site name (sanitized)',
|
||||
'[TYPE]' => 'Backup type (full, database, files, differential)',
|
||||
'[RANDOM]' => 'Random 6-character hex string',
|
||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||
'[HOME]' => 'Home directory of the PHP process owner',
|
||||
];
|
||||
@@ -62,21 +62,21 @@ class PlaceholderResolver
|
||||
}
|
||||
|
||||
$this->replacements = [
|
||||
'[host]' => $hostname,
|
||||
'[date]' => $now->format('Ymd'),
|
||||
'[time]' => $now->format('His'),
|
||||
'[datetime]' => $now->format('Ymd_His'),
|
||||
'[year]' => $now->format('Y'),
|
||||
'[month]' => $now->format('m'),
|
||||
'[day]' => $now->format('d'),
|
||||
'[hour]' => $now->format('H'),
|
||||
'[minute]' => $now->format('i'),
|
||||
'[second]' => $now->format('s'),
|
||||
'[profile_id]' => (string) ($profile->id ?? '0'),
|
||||
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
|
||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[type]' => $profile->backup_type ?? 'full',
|
||||
'[random]' => bin2hex(random_bytes(3)),
|
||||
'[HOST]' => $hostname,
|
||||
'[DATE]' => $now->format('Ymd'),
|
||||
'[TIME]' => $now->format('His'),
|
||||
'[DATETIME]' => $now->format('Ymd_His'),
|
||||
'[YEAR]' => $now->format('Y'),
|
||||
'[MONTH]' => $now->format('m'),
|
||||
'[DAY]' => $now->format('d'),
|
||||
'[HOUR]' => $now->format('H'),
|
||||
'[MINUTE]' => $now->format('i'),
|
||||
'[SECOND]' => $now->format('s'),
|
||||
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
|
||||
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
|
||||
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
|
||||
'[TYPE]' => $profile->backup_type ?? 'full',
|
||||
'[RANDOM]' => bin2hex(random_bytes(3)),
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
];
|
||||
@@ -103,7 +103,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getHostname(): string
|
||||
{
|
||||
return $this->replacements['[host]'];
|
||||
return $this->replacements['[HOST]'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +111,7 @@ class PlaceholderResolver
|
||||
*/
|
||||
public function getTag(): string
|
||||
{
|
||||
return $this->replacements['[datetime]'];
|
||||
return $this->replacements['[DATETIME]'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
<?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\Engine;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
/**
|
||||
* 7z archiver using the 7za/7z CLI binary.
|
||||
*
|
||||
* Requires p7zip-full (Linux) or 7-Zip (Windows) to be installed on the server.
|
||||
* Supports native AES-256 encryption via the -p flag.
|
||||
*/
|
||||
class SevenZipArchiver implements ArchiverInterface
|
||||
{
|
||||
/** @var string Absolute path to the target archive */
|
||||
private string $archivePath = '';
|
||||
|
||||
/** @var string[] Absolute paths of files to add */
|
||||
private array $filePaths = [];
|
||||
|
||||
/** @var string[] Corresponding local names inside the archive */
|
||||
private array $localNames = [];
|
||||
|
||||
/** @var string[] Temp files created by addFromString() that must be cleaned up */
|
||||
private array $tempFiles = [];
|
||||
|
||||
/** @var string Optional encryption password */
|
||||
private string $encryptionPassword = '';
|
||||
|
||||
/**
|
||||
* Set the encryption password for the archive.
|
||||
*
|
||||
* @param string $password Password for AES-256 encryption
|
||||
*/
|
||||
public function setEncryptionPassword(string $password): void
|
||||
{
|
||||
$this->encryptionPassword = $password;
|
||||
}
|
||||
|
||||
public function open(string $path): void
|
||||
{
|
||||
$this->archivePath = $path;
|
||||
$this->filePaths = [];
|
||||
$this->localNames = [];
|
||||
$this->tempFiles = [];
|
||||
|
||||
// Remove existing archive to avoid appending to stale data
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
public function addFromString(string $localName, string $contents): void
|
||||
{
|
||||
// Write to a temp file so 7z can read it from disk
|
||||
$tempDir = \dirname($this->archivePath);
|
||||
$tempFile = $tempDir . '/.7z-tmp-' . md5($localName . microtime(true)) . '-' . basename($localName);
|
||||
|
||||
if (file_put_contents($tempFile, $contents) === false) {
|
||||
throw new \RuntimeException('SevenZipArchiver: cannot write temp file: ' . $tempFile);
|
||||
}
|
||||
|
||||
$this->tempFiles[] = $tempFile;
|
||||
$this->filePaths[] = $tempFile;
|
||||
$this->localNames[] = $localName;
|
||||
}
|
||||
|
||||
public function addFile(string $filePath, string $localName): void
|
||||
{
|
||||
$this->filePaths[] = $filePath;
|
||||
$this->localNames[] = $localName;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
try {
|
||||
$this->buildArchive();
|
||||
} finally {
|
||||
// Always clean up temp files
|
||||
foreach ($this->tempFiles as $tempFile) {
|
||||
if (is_file($tempFile)) {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
$this->tempFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getExtension(): string
|
||||
{
|
||||
return '7z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 7z archive using the CLI binary.
|
||||
*
|
||||
* Writes a list file mapping local names to absolute paths, then invokes
|
||||
* 7za/7z to create the archive. Uses stdin rename pairs for correct
|
||||
* internal paths.
|
||||
*/
|
||||
private function buildArchive(): void
|
||||
{
|
||||
$binary = $this->findBinary();
|
||||
|
||||
if ($binary === null) {
|
||||
throw new \RuntimeException(
|
||||
'SevenZipArchiver: 7z/7za binary not found. '
|
||||
. 'Install p7zip-full (Linux) or 7-Zip (Windows).'
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($this->filePaths)) {
|
||||
throw new \RuntimeException('SevenZipArchiver: no files to archive');
|
||||
}
|
||||
|
||||
// Strategy: create a temporary staging directory with the correct
|
||||
// directory structure, symlink or copy files, then archive the
|
||||
// staging directory. This gives us correct internal paths.
|
||||
$stagingDir = \dirname($this->archivePath) . '/.7z-staging-' . md5($this->archivePath . microtime(true));
|
||||
|
||||
if (!mkdir($stagingDir, 0755, true)) {
|
||||
throw new \RuntimeException('SevenZipArchiver: cannot create staging directory: ' . $stagingDir);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the directory structure and link/copy files
|
||||
foreach ($this->filePaths as $i => $sourcePath) {
|
||||
$localName = $this->localNames[$i];
|
||||
$targetPath = $stagingDir . '/' . $localName;
|
||||
$targetDir = \dirname($targetPath);
|
||||
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true)) {
|
||||
throw new \RuntimeException('SevenZipArchiver: cannot create directory: ' . $targetDir);
|
||||
}
|
||||
|
||||
// Use symlink where possible (faster, no disk usage), fall back to copy
|
||||
if (@symlink($sourcePath, $targetPath) === false) {
|
||||
if (!copy($sourcePath, $targetPath)) {
|
||||
throw new \RuntimeException('SevenZipArchiver: cannot copy file: ' . $sourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build command
|
||||
$cmd = escapeshellarg($binary)
|
||||
. ' a'
|
||||
. ' -t7z'
|
||||
. ' -mx=5'
|
||||
. ' -mhe=on'
|
||||
. ' ' . escapeshellarg($this->archivePath)
|
||||
. ' ' . escapeshellarg($stagingDir . '/*');
|
||||
|
||||
// Add encryption if password is set
|
||||
if ($this->encryptionPassword !== '') {
|
||||
$cmd .= ' -p' . escapeshellarg($this->encryptionPassword);
|
||||
}
|
||||
|
||||
// Suppress interactive prompts
|
||||
$cmd .= ' -y';
|
||||
|
||||
// Redirect stderr to stdout for capture
|
||||
$cmd .= ' 2>&1';
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
$outputStr = implode("\n", $output);
|
||||
throw new \RuntimeException(
|
||||
'SevenZipArchiver: 7z exited with code ' . $exitCode . ': ' . $outputStr
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_file($this->archivePath)) {
|
||||
throw new \RuntimeException('SevenZipArchiver: archive was not created: ' . $this->archivePath);
|
||||
}
|
||||
|
||||
// The archive contains paths relative to the staging dir.
|
||||
// We need to verify that the internal structure doesn't include
|
||||
// the staging dir name as a prefix. If 7z was given staging/*,
|
||||
// the paths inside should be correct (relative to staging).
|
||||
} finally {
|
||||
// Remove staging directory
|
||||
$this->removeDirectory($stagingDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the 7z or 7za binary.
|
||||
*
|
||||
* @return string|null Absolute path to binary, or null if not found
|
||||
*/
|
||||
private function findBinary(): ?string
|
||||
{
|
||||
// Check common binary names
|
||||
$candidates = PHP_OS_FAMILY === 'Windows'
|
||||
? ['7z', '7za', 'C:\\Program Files\\7-Zip\\7z.exe', 'C:\\Program Files (x86)\\7-Zip\\7z.exe']
|
||||
: ['7za', '7z', '/usr/bin/7za', '/usr/bin/7z', '/usr/local/bin/7za', '/usr/local/bin/7z'];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
// If it's an absolute path, check file existence
|
||||
if (str_contains($candidate, DIRECTORY_SEPARATOR) || str_contains($candidate, '/')) {
|
||||
if (is_file($candidate) && is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use 'which' / 'where' to find in PATH
|
||||
$whichCmd = PHP_OS_FAMILY === 'Windows'
|
||||
? 'where ' . escapeshellarg($candidate) . ' 2>NUL'
|
||||
: 'which ' . escapeshellarg($candidate) . ' 2>/dev/null';
|
||||
|
||||
$result = trim((string) shell_exec($whichCmd));
|
||||
|
||||
if ($result !== '' && is_executable($result)) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove a directory and its contents.
|
||||
*/
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
if ($item->isDir()) {
|
||||
@rmdir($item->getPathname());
|
||||
} else {
|
||||
// Remove symlinks and files
|
||||
@unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,21 @@ class SteppedBackupEngine
|
||||
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tag = $resolver->getTag();
|
||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||
|
||||
// The stepped engine uses ZipArchive batch-by-batch, so only ZIP is
|
||||
// supported. For 7z / tar.gz the non-stepped BackupEngine must be used.
|
||||
if ($archiveFormat !== 'zip') {
|
||||
return [
|
||||
'error' => true,
|
||||
'message' => 'The stepped backup engine only supports ZIP format. '
|
||||
. 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.',
|
||||
];
|
||||
}
|
||||
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||
|
||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||
|
||||
@@ -52,15 +52,15 @@ class FolderPickerField extends FormField
|
||||
$placeholders = [
|
||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||
'[host]' => $hostname,
|
||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[profile_id]' => '1',
|
||||
'[profile_name]' => 'default',
|
||||
'[type]' => 'full',
|
||||
'[year]' => date('Y'),
|
||||
'[month]' => date('m'),
|
||||
'[day]' => date('d'),
|
||||
'[date]' => date('Ymd'),
|
||||
'[HOST]' => $hostname,
|
||||
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
|
||||
'[PROFILE_ID]' => '1',
|
||||
'[PROFILE_NAME]' => 'default',
|
||||
'[TYPE]' => 'full',
|
||||
'[YEAR]' => date('Y'),
|
||||
'[MONTH]' => date('m'),
|
||||
'[DAY]' => date('d'),
|
||||
'[DATE]' => date('Ymd'),
|
||||
];
|
||||
|
||||
$placeholdersJson = json_encode($placeholders);
|
||||
@@ -96,7 +96,7 @@ class FolderPickerField extends FormField
|
||||
<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">
|
||||
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
|
||||
<span class="icon-question-circle" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -104,12 +104,12 @@ class FolderPickerField extends FormField
|
||||
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[host]" title="Server hostname">[host]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[site_name]" title="Joomla site name">[site_name]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[date]" title="Date (Ymd)">[date]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_id]" title="Profile ID">[profile_id]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_name]" title="Profile name">[profile_name]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[type]" title="Backup type">[type]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
|
||||
</div>
|
||||
<div class="mt-1" id="{$id}_status">
|
||||
<small class="{$statusClass}">
|
||||
@@ -124,36 +124,112 @@ class FolderPickerField extends FormField
|
||||
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-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</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>
|
||||
|
||||
<h6 class="text-primary">How Path Resolution Works</h6>
|
||||
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Absolute Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>/home/user/backups</code> — Fixed path on the server</li>
|
||||
<li><code>/var/backups/joomla</code> — System backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Relative Paths</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
|
||||
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
|
||||
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
|
||||
<div class="card-body py-2">
|
||||
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
|
||||
<ul class="mb-0">
|
||||
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
|
||||
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
|
||||
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Available Placeholders</h6>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</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>
|
||||
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
|
||||
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
|
||||
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</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 (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
|
||||
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
|
||||
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
|
||||
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
|
||||
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</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>
|
||||
|
||||
<h6 class="text-primary mt-3">Recommended Configurations</h6>
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Single site, secure</strong></td>
|
||||
<td><code>[HOME]/backups</code></td>
|
||||
<td>Outside web root. Best for most sites.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Multiple sites on one server</strong></td>
|
||||
<td><code>[HOME]/backups/[HOST]</code></td>
|
||||
<td>Each site gets its own subdirectory.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Date-organized</strong></td>
|
||||
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
|
||||
<td>Backups sorted by year and month.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Per-profile</strong></td>
|
||||
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
|
||||
<td>Separate directory for each backup profile.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Shared hosting (default)</strong></td>
|
||||
<td><code>[DEFAULT_DIR]</code></td>
|
||||
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert alert-info py-2 mt-3 mb-0">
|
||||
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
@@ -188,6 +264,36 @@ class FolderPickerField extends FormField
|
||||
});
|
||||
});
|
||||
|
||||
/* Help button — open modal with Bootstrap 5 or fallback */
|
||||
var helpBtn = document.getElementById('{$id}_helpBtn');
|
||||
var helpModal = document.getElementById('{$id}_helpModal');
|
||||
if (helpBtn && helpModal) {
|
||||
helpBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
|
||||
modal.show();
|
||||
} else {
|
||||
helpModal.classList.add('show');
|
||||
helpModal.style.display = 'block';
|
||||
helpModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
var backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop fade show';
|
||||
backdrop.id = '{$id}_backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
|
||||
helpModal.classList.remove('show');
|
||||
helpModal.style.display = 'none';
|
||||
helpModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
var bd = document.getElementById('{$id}_backdrop');
|
||||
if (bd) bd.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fieldId = '{$id}';
|
||||
var btn = document.getElementById(fieldId + '_btn');
|
||||
var browser = document.getElementById(fieldId + '_browser');
|
||||
@@ -195,7 +301,7 @@ class FolderPickerField extends FormField
|
||||
var input = document.getElementById(fieldId);
|
||||
var placeholders = {$placeholdersJson};
|
||||
|
||||
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
||||
// Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
|
||||
function resolve(path) {
|
||||
for (var key in placeholders) {
|
||||
path = path.split(key).join(placeholders[key]);
|
||||
@@ -322,7 +428,7 @@ class FolderPickerField extends FormField
|
||||
fullResolved.className = 'mt-1';
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'text-muted';
|
||||
arrow.textContent = 'Resolves to: ';
|
||||
arrow.textContent = 'EXAMPLE: ';
|
||||
fullResolved.appendChild(arrow);
|
||||
var code = document.createElement('code');
|
||||
code.textContent = resolve(val);
|
||||
|
||||
@@ -33,8 +33,8 @@ class PlaceholderTextField extends FormField
|
||||
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
|
||||
|
||||
if (empty($placeholders)) {
|
||||
$placeholders = ['[host]', '[date]', '[datetime]', '[time]', '[year]', '[month]', '[day]',
|
||||
'[hour]', '[minute]', '[second]', '[profile_id]', '[profile_name]', '[site_name]', '[type]', '[random]'];
|
||||
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
|
||||
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
|
||||
}
|
||||
|
||||
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<?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
|
||||
*
|
||||
* SFTP remote path field with Browse Remote button and modal directory browser.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class SftpPathField extends FormField
|
||||
{
|
||||
protected $type = 'SftpPath';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
|
||||
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
||||
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return <<<HTML
|
||||
<div class="input-group">
|
||||
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
||||
class="form-control" maxlength="512"
|
||||
placeholder="/backups" />
|
||||
<button type="button" class="btn btn-outline-secondary" id="{$id}_browseBtn"
|
||||
title="Browse directories on the remote SFTP server">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse Remote
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal fade" id="{$id}_sftpModal" tabindex="-1" aria-labelledby="{$id}_sftpModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="{$id}_sftpModalLabel">
|
||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||
Browse Remote SFTP Directory
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="{$id}_sftpStatus" class="mb-2">
|
||||
<small class="text-muted">Click "Browse Remote" to connect...</small>
|
||||
</div>
|
||||
<div id="{$id}_sftpCurrent" class="mb-2 p-2 bg-light border rounded" style="font-family:monospace; font-size:0.85rem;">
|
||||
/
|
||||
</div>
|
||||
<div id="{$id}_sftpTree" class="border rounded" style="max-height:350px; overflow-y:auto;">
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
Click a directory to navigate into it. Click "Select This Directory" to use the current path.
|
||||
<br>SFTP credentials must be saved in the profile before browsing.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="{$id}_sftpSelect">
|
||||
<span class="icon-checkmark" aria-hidden="true"></span>
|
||||
Select This Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var fieldId = '{$id}';
|
||||
var input = document.getElementById(fieldId);
|
||||
var browseBtn = document.getElementById(fieldId + '_browseBtn');
|
||||
var modalEl = document.getElementById(fieldId + '_sftpModal');
|
||||
var treeEl = document.getElementById(fieldId + '_sftpTree');
|
||||
var statusEl = document.getElementById(fieldId + '_sftpStatus');
|
||||
var currentEl = document.getElementById(fieldId + '_sftpCurrent');
|
||||
var selectBtn = document.getElementById(fieldId + '_sftpSelect');
|
||||
var currentPath = '/';
|
||||
|
||||
function getProfileId() {
|
||||
var el = document.getElementById('jform_id');
|
||||
return el ? parseInt(el.value, 10) || 0 : 0;
|
||||
}
|
||||
|
||||
function showModal() {
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the status message using safe DOM methods (no innerHTML).
|
||||
* @param {string} cssClass - CSS class for the small element
|
||||
* @param {string} iconClass - Icon CSS class (e.g. 'icon-spinner icon-spin'), or empty
|
||||
* @param {string} text - Plain text message
|
||||
*/
|
||||
function setStatus(cssClass, iconClass, text) {
|
||||
while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild);
|
||||
var small = document.createElement('small');
|
||||
small.className = cssClass;
|
||||
if (iconClass) {
|
||||
var icon = document.createElement('span');
|
||||
icon.className = iconClass;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
small.appendChild(icon);
|
||||
small.appendChild(document.createTextNode(' '));
|
||||
}
|
||||
small.appendChild(document.createTextNode(text));
|
||||
statusEl.appendChild(small);
|
||||
}
|
||||
|
||||
function loadSftpDir(path) {
|
||||
currentPath = path;
|
||||
currentEl.textContent = path;
|
||||
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||
setStatus('text-muted', 'icon-spinner icon-spin', 'Connecting to remote server...');
|
||||
|
||||
var profileId = getProfileId();
|
||||
if (!profileId) {
|
||||
setStatus('text-danger', '', 'Please save the profile first so SFTP credentials are available.');
|
||||
return;
|
||||
}
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.browseSftpDir');
|
||||
form.append('profile_id', profileId);
|
||||
form.append('path', path);
|
||||
|
||||
var tokenName = Joomla.getOptions('csrf.token') || '';
|
||||
if (tokenName) form.append(tokenName, '1');
|
||||
|
||||
fetch('index.php?option=com_mokosuitebackup&format=json', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')');
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
setStatus('text-danger', 'icon-warning', data.message || 'Error');
|
||||
return;
|
||||
}
|
||||
var count = data.dirs ? data.dirs.length : 0;
|
||||
setStatus('text-success', 'icon-publish', 'Connected \u2014 ' + count + ' subdirectories');
|
||||
currentPath = data.current || path;
|
||||
currentEl.textContent = currentPath;
|
||||
renderSftpTree(data);
|
||||
})
|
||||
.catch(function(err) {
|
||||
setStatus('text-danger', 'icon-warning', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSftpTree(data) {
|
||||
while (treeEl.firstChild) treeEl.removeChild(treeEl.firstChild);
|
||||
var list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
|
||||
/* Parent / back button */
|
||||
if (data.parent !== null && data.parent !== undefined) {
|
||||
var up = document.createElement('a');
|
||||
up.href = '#';
|
||||
up.className = 'list-group-item list-group-item-action py-1';
|
||||
var upIcon = document.createElement('span');
|
||||
upIcon.className = 'icon-arrow-up-4';
|
||||
upIcon.setAttribute('aria-hidden', 'true');
|
||||
up.appendChild(upIcon);
|
||||
up.appendChild(document.createTextNode(' .. (parent directory)'));
|
||||
up.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadSftpDir(data.parent);
|
||||
});
|
||||
list.appendChild(up);
|
||||
}
|
||||
|
||||
/* Directory entries */
|
||||
var dirs = data.dirs || [];
|
||||
|
||||
dirs.forEach(function(dir) {
|
||||
var item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'list-group-item list-group-item-action py-1';
|
||||
var folderIcon = document.createElement('span');
|
||||
folderIcon.className = 'icon-folder';
|
||||
folderIcon.setAttribute('aria-hidden', 'true');
|
||||
item.appendChild(folderIcon);
|
||||
item.appendChild(document.createTextNode(' ' + dir.name));
|
||||
|
||||
item.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
loadSftpDir(dir.path);
|
||||
});
|
||||
|
||||
/* Double-click to select and close */
|
||||
item.addEventListener('dblclick', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = dir.path;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
hideModal();
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
if (dirs.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'list-group-item text-muted py-2';
|
||||
empty.textContent = '(no subdirectories)';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
treeEl.appendChild(list);
|
||||
}
|
||||
|
||||
/* Browse button click */
|
||||
browseBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var startPath = input.value.trim() || '/';
|
||||
showModal();
|
||||
loadSftpDir(startPath);
|
||||
});
|
||||
|
||||
/* Select button — use the current directory */
|
||||
selectBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
input.value = currentPath;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
hideModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,6 @@ HTACCESS;
|
||||
*/
|
||||
public static function logPathFromArchive(string $archivePath): string
|
||||
{
|
||||
return preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath);
|
||||
return preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ class HtmlView extends BaseHtmlView
|
||||
|
||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
|
||||
}
|
||||
|
||||
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
||||
|
||||
@@ -65,7 +65,7 @@ class HtmlView extends BaseHtmlView
|
||||
}
|
||||
|
||||
// "View Backups" link button
|
||||
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $profileId);
|
||||
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||
->url($backupsUrl)
|
||||
->icon('icon-database')
|
||||
|
||||
@@ -695,6 +695,45 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge Backups Modal -->
|
||||
<?php $canDelete = $user->authorise('core.delete', 'com_mokosuitebackup'); ?>
|
||||
<?php if ($canDelete) : ?>
|
||||
<div id="mb-purge-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:8% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;">
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_TITLE'); ?>
|
||||
</h4>
|
||||
<button type="button" class="btn-close mb-purge-close" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=backups.purge'); ?>" method="post" id="mb-purge-form">
|
||||
<div style="padding:1.5rem;">
|
||||
<p><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DESC'); ?></p>
|
||||
<div class="mb-3">
|
||||
<label for="mb-purge-date" class="form-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_DATE_LABEL'); ?></label>
|
||||
<input type="date" class="form-control" id="mb-purge-date" name="purge_date" required>
|
||||
</div>
|
||||
<div id="mb-purge-count-wrapper" style="display:none;">
|
||||
<div class="alert alert-danger mb-0" id="mb-purge-count-msg"></div>
|
||||
</div>
|
||||
<div id="mb-purge-none-wrapper" style="display:none;">
|
||||
<div class="alert alert-info mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_NONE_FOUND'); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 1.5rem 1.5rem; text-align:right;">
|
||||
<button type="button" class="btn btn-secondary mb-purge-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||
<button type="submit" class="btn btn-danger" id="mb-purge-submit" disabled>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_SUBMIT'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php echo HTMLHelper::_('form.token'); ?>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Backup Comparison Modal -->
|
||||
<div id="mb-compare-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:85vh;">
|
||||
@@ -863,3 +902,114 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php if ($canDelete) : ?>
|
||||
<script>
|
||||
(function() {
|
||||
var PURGE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||
var PURGE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||
var purgeCountTimer = null;
|
||||
|
||||
// Intercept Purge toolbar button to show the modal
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var purgeBtn = document.querySelector('[onclick*="backups.purgeModal"], .button-trash');
|
||||
if (purgeBtn) {
|
||||
purgeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Reset modal state
|
||||
document.getElementById('mb-purge-date').value = '';
|
||||
document.getElementById('mb-purge-count-wrapper').style.display = 'none';
|
||||
document.getElementById('mb-purge-none-wrapper').style.display = 'none';
|
||||
document.getElementById('mb-purge-submit').disabled = true;
|
||||
document.getElementById('mb-purge-modal').style.display = 'block';
|
||||
return false;
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Date change triggers count lookup with debounce
|
||||
var dateInput = document.getElementById('mb-purge-date');
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener('change', function() {
|
||||
if (purgeCountTimer) clearTimeout(purgeCountTimer);
|
||||
purgeCountTimer = setTimeout(fetchPurgeCount, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'mb-purge-modal' || e.target.classList.contains('mb-purge-close')) {
|
||||
document.getElementById('mb-purge-modal').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm on submit
|
||||
var purgeForm = document.getElementById('mb-purge-form');
|
||||
if (purgeForm) {
|
||||
purgeForm.addEventListener('submit', function(e) {
|
||||
var msg = document.getElementById('mb-purge-count-msg').textContent;
|
||||
if (!confirm(msg + '\n\n<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_CONFIRM', true); ?>')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function fetchPurgeCount() {
|
||||
var dateVal = document.getElementById('mb-purge-date').value;
|
||||
var countWrapper = document.getElementById('mb-purge-count-wrapper');
|
||||
var noneWrapper = document.getElementById('mb-purge-none-wrapper');
|
||||
var countMsg = document.getElementById('mb-purge-count-msg');
|
||||
var submitBtn = document.getElementById('mb-purge-submit');
|
||||
|
||||
if (!dateVal) {
|
||||
countWrapper.style.display = 'none';
|
||||
noneWrapper.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
countMsg.textContent = '<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING', true); ?>';
|
||||
countWrapper.style.display = 'block';
|
||||
noneWrapper.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
var form = new URLSearchParams();
|
||||
form.append('task', 'ajax.countPurge');
|
||||
form.append('date', dateVal);
|
||||
form.append(PURGE_TOKEN, '1');
|
||||
|
||||
fetch(PURGE_AJAX_URL, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
countMsg.textContent = data.message || 'Error';
|
||||
countWrapper.style.display = 'block';
|
||||
noneWrapper.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
} else if (data.count === 0) {
|
||||
countWrapper.style.display = 'none';
|
||||
noneWrapper.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
} else {
|
||||
var text = '<?php echo Text::_('COM_MOKOJOOMBACKUP_PURGE_COUNT_MSG', true); ?>';
|
||||
countMsg.textContent = text.replace('%d', data.count);
|
||||
countWrapper.style.display = 'block';
|
||||
noneWrapper.style.display = 'none';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
countMsg.textContent = 'Error: ' + err.message;
|
||||
countWrapper.style.display = 'block';
|
||||
noneWrapper.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -78,7 +78,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<?php echo $this->escape($item->backup_type); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $item->id); ?>">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
|
||||
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||
<?php echo (int) $item->backup_count; ?>
|
||||
</span>
|
||||
|
||||
@@ -403,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
var label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.setAttribute('for', 'mb-rtype-' + type);
|
||||
label.textContent = typeLabels[type] || type;
|
||||
label.textContent = typeLabels[TYPE] || type;
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(label);
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
; MokoSuiteBackup — CPanel Module 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
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED="MokoSuiteBackup is not installed or is disabled."
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP="Last Backup"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK="Success"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL="Failed"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS="No backups yet."
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES="%d files, %d tables"
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED="Next Scheduled"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_TOTAL="total"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_STREAK="streak"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D="failed (7d)"
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW="Backup Now"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS="Backup in Progress"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE="Backup Complete"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE="Do not navigate away or close this window while the backup is running."
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS="View Backups"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT="Create Snapshot"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES="View Profiles"
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS="Show Backup Now Buttons"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE="Show Next Scheduled"
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
; MokoSuiteBackup — CPanel Module 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
|
||||
|
||||
MOD_MOKOSUITEBACKUP_CPANEL="MokoSuiteBackup CPanel"
|
||||
MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION="Displays backup status, Backup Now buttons, and quick links on the admin dashboard."
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage mod_mokosuitebackup_cpanel
|
||||
* @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="module" client="administrator" method="upgrade">
|
||||
<name>mod_mokosuitebackup_cpanel</name>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-23</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
<authorUrl>https://mokoconsulting.tech</authorUrl>
|
||||
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<description>MOD_MOKOSUITEBACKUP_CPANEL_DESCRIPTION</description>
|
||||
|
||||
<namespace path="src">Joomla\Module\MokoSuiteBackupCpanel</namespace>
|
||||
|
||||
<files>
|
||||
<folder>language</folder>
|
||||
<folder>services</folder>
|
||||
<folder>src</folder>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
|
||||
<languages folder="language">
|
||||
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.ini</language>
|
||||
<language tag="en-GB">en-GB/mod_mokosuitebackup_cpanel.sys.ini</language>
|
||||
</languages>
|
||||
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic">
|
||||
<field
|
||||
name="show_backup_buttons"
|
||||
type="radio"
|
||||
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_BUTTONS"
|
||||
default="1"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
<field
|
||||
name="show_schedule"
|
||||
type="radio"
|
||||
label="MOD_MOKOSUITEBACKUP_CPANEL_PARAM_SHOW_SCHEDULE"
|
||||
default="1"
|
||||
class="btn-group btn-group-yesno"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
</field>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage mod_mokosuitebackup_cpanel
|
||||
* @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\Service\Provider\HelperFactory;
|
||||
use Joomla\CMS\Extension\Service\Provider\Module;
|
||||
use Joomla\CMS\Extension\Service\Provider\ModuleDispatcherFactory;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
public function register(Container $container): void
|
||||
{
|
||||
$container->registerServiceProvider(new ModuleDispatcherFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel'));
|
||||
$container->registerServiceProvider(new HelperFactory('\\Joomla\\Module\\MokoSuiteBackupCpanel\\Administrator\\Helper'));
|
||||
$container->registerServiceProvider(new Module());
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage mod_mokosuitebackup_cpanel
|
||||
* @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\Module\MokoSuiteBackupCpanel\Administrator\Dispatcher;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Dispatcher\AbstractModuleDispatcher;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Helper\BackupStatusHelper;
|
||||
|
||||
class Dispatcher extends AbstractModuleDispatcher
|
||||
{
|
||||
/**
|
||||
* Returns the layout data for the module template.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getLayoutData(): array
|
||||
{
|
||||
$data = parent::getLayoutData();
|
||||
|
||||
$db = Factory::getContainer()->get('DatabaseDriver');
|
||||
|
||||
// Status summary from the shared helper
|
||||
$status = BackupStatusHelper::getStatusSummary();
|
||||
|
||||
// Published profiles for "Backup Now" buttons
|
||||
$profiles = [];
|
||||
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['id', 'title', 'backup_type']))
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||
->where($db->quoteName('published') . ' = 1')
|
||||
->order($db->quoteName('ordering') . ' ASC');
|
||||
$db->setQuery($query);
|
||||
$profiles = $db->loadObjectList() ?: [];
|
||||
} catch (\Throwable $e) {
|
||||
// Component may not be installed yet
|
||||
}
|
||||
|
||||
// Next scheduled backup
|
||||
$nextScheduled = null;
|
||||
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['t.next_execution', 't.title']))
|
||||
->from($db->quoteName('#__scheduler_tasks', 't'))
|
||||
->where($db->quoteName('t.type') . ' = ' . $db->quote('mokosuitebackup.run_profile'))
|
||||
->where($db->quoteName('t.state') . ' = 1')
|
||||
->order($db->quoteName('t.next_execution') . ' ASC');
|
||||
$db->setQuery($query, 0, 1);
|
||||
$nextScheduled = $db->loadObject() ?: null;
|
||||
} catch (\Throwable $e) {
|
||||
// Scheduler may not exist
|
||||
}
|
||||
|
||||
$data['status'] = $status;
|
||||
$data['profiles'] = $profiles;
|
||||
$data['nextScheduled'] = $nextScheduled;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package MokoSuiteBackup
|
||||
* @subpackage mod_mokosuitebackup_cpanel
|
||||
* @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\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
/** @var array $displayData */
|
||||
$status = $displayData['status'];
|
||||
$profiles = $displayData['profiles'];
|
||||
$nextScheduled = $displayData['nextScheduled'];
|
||||
$params = $displayData['params'];
|
||||
|
||||
$showButtons = (int) $params->get('show_backup_buttons', 1);
|
||||
$showSchedule = (int) $params->get('show_schedule', 1);
|
||||
|
||||
$latest = $status['latest'] ?? null;
|
||||
$installed = $status['installed'] ?? false;
|
||||
$totals = $status['totals'] ?? [];
|
||||
|
||||
$ajaxToken = Session::getFormToken();
|
||||
$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false);
|
||||
|
||||
$moduleId = 'mod-msb-cpanel-' . $displayData['module']->id;
|
||||
?>
|
||||
|
||||
<?php if (!$installed) : ?>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NOT_INSTALLED'); ?>
|
||||
</div>
|
||||
<?php return; endif; ?>
|
||||
|
||||
<div id="<?php echo $moduleId; ?>" class="mod-mokosuitebackup-cpanel">
|
||||
<!-- Last Backup Status -->
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small mb-2">
|
||||
<span class="icon-database" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LAST_BACKUP'); ?>
|
||||
</h6>
|
||||
<?php if ($latest) : ?>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="badge <?php echo $latest['status'] === 'complete' ? 'bg-success' : 'bg-danger'; ?>">
|
||||
<?php echo $latest['status'] === 'complete'
|
||||
? Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_OK')
|
||||
: Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STATUS_FAIL'); ?>
|
||||
</span>
|
||||
<span class="ms-1 small text-muted">
|
||||
<?php echo htmlspecialchars($latest['profile'] ?? ''); ?>
|
||||
</span>
|
||||
</div>
|
||||
<span class="small text-muted">
|
||||
<?php echo HTMLHelper::_('date', $latest['backup_start'], Text::_('DATE_FORMAT_LC4')); ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<?php echo HTMLHelper::_('number.bytes', (int) $latest['total_size']); ?>
|
||||
— <?php echo Text::sprintf('MOD_MOKOSUITEBACKUP_CPANEL_FILES_TABLES', (int) $latest['files_count'], (int) $latest['tables_count']); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p class="text-muted small mb-0"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NO_BACKUPS'); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Next Scheduled -->
|
||||
<?php if ($showSchedule && $nextScheduled) : ?>
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small mb-1">
|
||||
<span class="icon-calendar" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_NEXT_SCHEDULED'); ?>
|
||||
</h6>
|
||||
<div class="small">
|
||||
<?php echo HTMLHelper::_('date', $nextScheduled->next_execution, Text::_('DATE_FORMAT_LC4')); ?>
|
||||
<span class="text-muted">— <?php echo htmlspecialchars($nextScheduled->title); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Stats row -->
|
||||
<?php if (!empty($totals)) : ?>
|
||||
<div class="d-flex gap-3 mb-3 small">
|
||||
<div>
|
||||
<span class="fw-bold"><?php echo (int) ($totals['all_time'] ?? 0); ?></span>
|
||||
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_TOTAL'); ?></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="fw-bold text-success"><?php echo (int) ($totals['success_streak'] ?? 0); ?></span>
|
||||
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_STREAK'); ?></span>
|
||||
</div>
|
||||
<?php if (($totals['recent_failed'] ?? 0) > 0) : ?>
|
||||
<div>
|
||||
<span class="fw-bold text-danger"><?php echo (int) $totals['recent_failed']; ?></span>
|
||||
<span class="text-muted"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_FAILED_7D'); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Backup Now Buttons -->
|
||||
<?php if ($showButtons && !empty($profiles)) : ?>
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted text-uppercase small mb-2">
|
||||
<span class="icon-download" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_NOW'); ?>
|
||||
</h6>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<?php foreach ($profiles as $profile) : ?>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-primary msb-cpanel-backup-btn"
|
||||
data-profile-id="<?php echo (int) $profile->id; ?>"
|
||||
data-module-id="<?php echo $moduleId; ?>">
|
||||
<?php echo htmlspecialchars($profile->title); ?>
|
||||
<span class="badge bg-secondary ms-1"><?php echo htmlspecialchars($profile->backup_type); ?></span>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="list-group list-group-flush small">
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups'); ?>"
|
||||
class="list-group-item list-group-item-action px-0 py-1">
|
||||
<span class="icon-database" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_BACKUPS'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots&task=snapshot.add'); ?>"
|
||||
class="list-group-item list-group-item-action px-0 py-1">
|
||||
<span class="icon-camera" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_SNAPSHOT'); ?>
|
||||
</a>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=profiles'); ?>"
|
||||
class="list-group-item list-group-item-action px-0 py-1">
|
||||
<span class="icon-cog" aria-hidden="true"></span>
|
||||
<?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_LINK_PROFILES'); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stepped Backup Modal -->
|
||||
<div id="<?php echo $moduleId; ?>-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="<?php echo $moduleId; ?>-modal-title" style="margin:0 0 1rem;"><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_IN_PROGRESS'); ?></h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong><?php echo Text::_('MOD_MOKOSUITEBACKUP_CPANEL_DO_NOT_CLOSE'); ?></strong>
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="<?php echo $moduleId; ?>-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
<p id="<?php echo $moduleId; ?>-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
|
||||
<p id="<?php echo $moduleId; ?>-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var MOD_ID = <?php echo json_encode($moduleId); ?>;
|
||||
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||
var TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||
|
||||
var running = false;
|
||||
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (running) { e.preventDefault(); e.returnValue = ''; }
|
||||
});
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function showModal() {
|
||||
running = true;
|
||||
el(MOD_ID + '-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
running = false;
|
||||
el(MOD_ID + '-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
function updateProgress(pct, msg, phase) {
|
||||
var bar = el(MOD_ID + '-progress-bar');
|
||||
bar.style.width = pct + '%';
|
||||
bar.textContent = pct + '%';
|
||||
el(MOD_ID + '-status').textContent = msg;
|
||||
el(MOD_ID + '-phase').textContent = 'Phase: ' + phase;
|
||||
}
|
||||
|
||||
async function postAjax(params) {
|
||||
var form = new URLSearchParams();
|
||||
form.append(TOKEN, '1');
|
||||
for (var k in params) { form.append(k, params[k]); }
|
||||
var res = await fetch(AJAX_URL, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function startBackup(profileId) {
|
||||
showModal();
|
||||
updateProgress(0, 'Initializing backup...', 'init');
|
||||
|
||||
try {
|
||||
var initResult = await postAjax({ task: 'ajax.init', profile_id: profileId });
|
||||
if (initResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + initResult.message, 'failed');
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
var sessionId = initResult.session_id;
|
||||
updateProgress(initResult.progress, initResult.message, initResult.phase);
|
||||
|
||||
var done = false;
|
||||
while (!done) {
|
||||
var stepResult = await postAjax({ task: 'ajax.step', session_id: sessionId });
|
||||
if (stepResult.error) {
|
||||
updateProgress(0, 'ERROR: ' + stepResult.message, 'failed');
|
||||
setTimeout(hideModal, 5000);
|
||||
return;
|
||||
}
|
||||
updateProgress(stepResult.progress, stepResult.message, stepResult.phase);
|
||||
done = stepResult.done || false;
|
||||
}
|
||||
|
||||
el(MOD_ID + '-modal-title').textContent = <?php echo json_encode(Text::_('MOD_MOKOSUITEBACKUP_CPANEL_BACKUP_COMPLETE')); ?>;
|
||||
setTimeout(function() { hideModal(); location.reload(); }, 2000);
|
||||
} catch (err) {
|
||||
updateProgress(0, 'ERROR: ' + err.message, 'failed');
|
||||
setTimeout(hideModal, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('#' + MOD_ID + ' .msb-cpanel-backup-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
startBackup(this.getAttribute('data-profile-id'));
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="content" method="upgrade">
|
||||
<name>Content - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="quickicon" method="upgrade">
|
||||
<name>Quick Icon - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>System - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="task" method="upgrade">
|
||||
<name>Task - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="webservices" method="upgrade">
|
||||
<name>Web Services - MokoSuiteBackup</name>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.38.03</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
@@ -28,6 +28,7 @@
|
||||
<file type="plugin" id="mokosuitebackup" group="console">plg_console_mokosuitebackup.zip</file>
|
||||
<file type="plugin" id="mokosuitebackup" group="content">plg_content_mokosuitebackup.zip</file>
|
||||
<file type="plugin" id="mokosuitebackup" group="actionlog">plg_actionlog_mokosuitebackup.zip</file>
|
||||
<file type="module" id="mod_mokosuitebackup_cpanel" client="administrator">mod_mokosuitebackup_cpanel.zip</file>
|
||||
</files>
|
||||
|
||||
<languages>
|
||||
|
||||
Reference in New Issue
Block a user