Compare commits
25 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 |
@@ -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.39.00
|
||||
# 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
|
||||
+18
-2
@@ -1,9 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [01.39.00] --- 2026-06-23
|
||||
## [01.39.01] --- 2026-06-23
|
||||
|
||||
## [01.39.00] --- 2026-06-23
|
||||
## [01.39.01] --- 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)
|
||||
|
||||
### 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.05] --- 2026-06-23
|
||||
|
||||
|
||||
@@ -1,50 +1,80 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.39.00 -->
|
||||
|
||||
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"
|
||||
@@ -291,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)"
|
||||
@@ -449,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.39.00</version>
|
||||
<version>01.39.01</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -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,6 +87,12 @@ 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]';
|
||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||
@@ -228,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,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);
|
||||
}
|
||||
@@ -472,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),
|
||||
};
|
||||
}
|
||||
@@ -577,6 +586,13 @@ class BackupEngine
|
||||
return;
|
||||
}
|
||||
|
||||
// 7z verification via CLI
|
||||
if ($extension === '7z') {
|
||||
$this->verify7zArchive($archivePath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP verification
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
@@ -638,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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
+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.39.00</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.39.00</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.39.00</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.39.00</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.39.00</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.39.00</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.39.00</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.39.00</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