Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e633d0cc0a | |||
| ff7418721d | |||
| 0b2b885163 | |||
| 6c47838b30 | |||
| 0f95cb6e9f | |||
| 1da2fdb856 | |||
| 4bafaa519a | |||
| 3c32bd93e9 | |||
| ef17873448 | |||
| dae30161ae | |||
| 8e70bfb723 | |||
| dcd772018e | |||
| 26d765b74e | |||
| 78b68d2647 | |||
| 50a879155d | |||
| b4fb674566 | |||
| 1b93d2ac21 | |||
| 8e5913d706 | |||
| 1f7def05c1 | |||
| 95317fb707 | |||
| cb5ff2843d | |||
| 4e6369094b | |||
| 0fbcc861d9 | |||
| 8cea58d1f6 | |||
| 84511b08d2 | |||
| 899a33bc58 | |||
| 7970597fb8 | |||
| 13f1c1db5e | |||
| 7ea30aa146 | |||
| d96f3e7760 | |||
| 10b31fea84 | |||
| 997924a107 | |||
| 9319abec41 | |||
| 7e404b0246 | |||
| 6638577cf5 | |||
| 114995242d | |||
| 3d6c0974fa | |||
| 8aefc1d702 | |||
| da52a9d2f9 | |||
| 0dc0eb1bef | |||
| 1def73df19 | |||
| 48f132ecf9 | |||
| c17349277d | |||
| 5a6ad02b53 | |||
| 29da9776cd | |||
| 09bac755a9 | |||
| f830dc2ddf | |||
| 5698c074da | |||
| aaf189b87a | |||
| 61023821e6 | |||
| 02a6e30db1 | |||
| 5a0cd51df6 | |||
| 12c832d7fe | |||
| 65c8820db4 | |||
| 0f914c3061 | |||
| 4191f44c1b | |||
| fb99afbeba | |||
| de632e9c5c | |||
| 53ff99148c | |||
| c2ff3b272a | |||
| 747b68c179 | |||
| cbff40d04c | |||
| e415e701cd | |||
| d184ed9de0 | |||
| 297f27c807 | |||
| 30e8d7baa9 | |||
| efc5754bef | |||
| e3e422d29e | |||
| 9f5c8c0b5e | |||
| 044e57adf3 | |||
| e7f165ac96 | |||
| fc41e1801a | |||
| 1aa35dd041 | |||
| 6a1f4a8797 | |||
| 6f6a6c705b | |||
| e8d7d1d421 | |||
| cd31617e21 | |||
| 6d9d96d7cd | |||
| df7c07bec4 | |||
| 5b4717bf6f | |||
| 65d30613b2 | |||
| d5bbab7e72 | |||
| 18b65d30ac | |||
| f55b032cc9 | |||
| e62dba8f40 | |||
| 0619825f38 | |||
| 70d7da34b3 | |||
| 13c251196b | |||
| 4841f24eab | |||
| 64ffbb9d61 | |||
| 83e91c6fa6 | |||
| b1833825e7 | |||
| bde20e82ad | |||
| 8348d23fe4 | |||
| d9557489d5 | |||
| 089ec69595 | |||
| 7427cbb043 | |||
| 456e744d81 | |||
| 6d5ef50727 | |||
| 00e7963988 | |||
| bc06657317 | |||
| bda4b0a23d | |||
| e327f9cf5c | |||
| 5b9351e5f0 | |||
| 5785e9fd1e | |||
| 1e9c8d54f4 | |||
| 7515274712 | |||
| 0be459fe34 | |||
| 11ccdbfde4 | |||
| fd517c16f3 | |||
| fe76f81b47 | |||
| 18127454b5 | |||
| 7826c315b1 | |||
| e329dbd99b | |||
| d6b3e8cff0 | |||
| 80c97620a5 | |||
| 33d852bacf | |||
| 8be0500913 | |||
| 27dded6c62 | |||
| e465dfa6ee | |||
| 3ac0318ba3 | |||
| 17e4625448 | |||
| eb748323f7 | |||
| bc3085f74b | |||
| f66100f74f | |||
| be8b1f73bf | |||
| 0f2c4fc238 | |||
| d0fe641d5c | |||
| 4a2520a43b | |||
| 54c3a6e2e9 | |||
| a27ec0f0b9 | |||
| a7c30ad67c | |||
| ee21f7a373 | |||
| 5c0ff72d27 | |||
| 50c016d707 | |||
| e4de103a00 | |||
| 8c66fd3260 | |||
| 4213def0ad | |||
| 8a4ebe1bde | |||
| 8ea09ee0d1 |
@@ -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
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.32.00
|
# VERSION: 01.41.03
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
@@ -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
|
|
||||||
+120
-25
@@ -1,31 +1,126 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.32.00] --- 2026-06-22
|
## [01.41.00] — 2026-06-23
|
||||||
|
|
||||||
## [01.32.00] --- 2026-06-22
|
### Added — Multi-Remote Storage
|
||||||
|
- New `#__mokosuitebackup_remotes` table for multiple destinations per profile
|
||||||
|
- Remote destinations UI: AJAX-driven add/edit/delete/toggle modal on profile edit
|
||||||
|
- Engine uploads to ALL enabled destinations (BackupEngine + SteppedBackupEngine)
|
||||||
|
- Migration auto-converts existing SFTP/S3/GDrive/FTP profile columns to new table
|
||||||
|
- Backward compatibility: falls back to legacy single-remote columns if table empty
|
||||||
|
- Secrets masked in API responses, merged from DB on save
|
||||||
|
|
||||||
|
### Added — Content Snapshots
|
||||||
|
- Lightweight JSON snapshots of articles, categories, and modules
|
||||||
|
- Includes tags, custom fields, workflow associations, field values
|
||||||
|
- Restore modes: Replace (clean slate), Merge (upsert), Selective (per-article)
|
||||||
|
- Snapshot retention: max count + max age with automatic cleanup
|
||||||
|
- Scheduled snapshot task via com_scheduler
|
||||||
|
- CLI: `mokosuitebackup:snapshot create|restore|list|delete`
|
||||||
|
- REST API: create, list, restore, delete, download snapshots
|
||||||
|
- Tabbed browse modal: Articles / Categories / Modules with item counts
|
||||||
|
|
||||||
|
### Added — SFTP Remote Storage
|
||||||
|
- SFTP support with SSH key file authentication (key stored base64 in database)
|
||||||
|
- Auth type dropdown: Password / Key File / Key File + Passphrase
|
||||||
|
- SshKeyField: file upload via FileReader, key never exposed in HTML
|
||||||
|
- SFTP remote directory browser for path selection
|
||||||
|
- `__KEEP_EXISTING__` sentinel preserves key on profile re-save
|
||||||
|
|
||||||
|
### Added — MokoRestore Wizard (9 steps)
|
||||||
|
- Per-table conflict resolution: Replace / Skip / Merge / Data Only
|
||||||
|
- Preset buttons: "All Replace", "All Skip", "Everything except users"
|
||||||
|
- Post-restore actions: reset passwords, hits, versions, sessions, cache
|
||||||
|
- Auto-detect sanitized passwords and prompt for reset (random temp password)
|
||||||
|
- Standalone mode: restore.php scans directory for ZIP files
|
||||||
|
- Wrapped mode: restore.php bundled inside backup ZIP
|
||||||
|
- Security gate with filesystem verification + path traversal protection
|
||||||
|
|
||||||
|
### Added — Data Sanitization
|
||||||
|
- Sanitize user passwords: replace hashes with invalid sentinel
|
||||||
|
- Sanitize user emails: replace with dummy values
|
||||||
|
- Clear session data: exclude `#__session` table
|
||||||
|
- Preserve super admin credentials (optional)
|
||||||
|
- GDPR-friendly backup sharing for demos and staging sites
|
||||||
|
|
||||||
|
### Added — Backup Engine
|
||||||
|
- Pre-flight validation: directory, disk space, extensions, credentials, running backups
|
||||||
|
- Auto-verify archive integrity after creation (ZIP, tar.gz, 7z)
|
||||||
|
- 7z archive format via system 7za/7z CLI binary with native encryption
|
||||||
|
- Streaming database dump to temp file (prevents OOM on large sites)
|
||||||
|
- S3 streaming upload via CURLOPT_PUT (prevents OOM)
|
||||||
|
- Graceful remote degradation: local backup preserved if upload fails
|
||||||
|
- DatabaseDumper::dumpToFile() for memory-efficient operation
|
||||||
|
|
||||||
|
### Added — Admin UI
|
||||||
|
- Dashboard: snapshot widget, 30-day backup trend chart, per-profile storage breakdown
|
||||||
|
- CPanel admin dashboard module (mod_mokosuitebackup_cpanel) with quick actions
|
||||||
|
- Backup type filter dropdown in backups list
|
||||||
|
- Backup comparison: select two backups for side-by-side diff
|
||||||
|
- Archive browser: view files inside backup without extracting
|
||||||
|
- Manual purge: delete backups older than a date with count preview
|
||||||
|
- Run Backup button on profile list and edit views with backup count badges
|
||||||
|
- "Do not navigate away" warning in backup/restore progress modals
|
||||||
|
- Clickable placeholder pills for backup directory and archive name fields
|
||||||
|
- Comprehensive help modal with absolute/relative/placeholder path documentation
|
||||||
|
- Placeholder resolution display with EXAMPLE prefix
|
||||||
|
- All placeholders UPPERCASE: [HOST], [SITE_NAME], [DATE], [DATETIME], etc.
|
||||||
|
|
||||||
|
### Added — CLI & API
|
||||||
|
- `mokosuitebackup:restore` with --files-only, --db-only, --password options
|
||||||
|
- `mokosuitebackup:snapshot` with create, restore, list, delete actions
|
||||||
|
- REST API for snapshots: create, list, restore, delete, download
|
||||||
|
- Profile credentials masked in API responses
|
||||||
|
|
||||||
|
### Added — Notifications & Logging
|
||||||
|
- Email/ntfy notifications for site restore, snapshot create/restore
|
||||||
|
- Joomla Action Logs for restore, snapshot, and snapshot restore events
|
||||||
|
- Global ntfy server/topic/token settings (fallback for profiles)
|
||||||
|
|
||||||
|
### Added — Security & Configuration
|
||||||
|
- Webcron secret field with CSPRNG generator + strength meter
|
||||||
|
- IP whitelist field with current IP detection + one-click "Add my IP"
|
||||||
|
- 10 ACL permissions with full enforcement audit across all controllers
|
||||||
|
- Config defaults: archive format, MokoRestore mode, sanitization settings
|
||||||
|
- Path traversal protection on all archive extraction (ZIP, tar.gz, JPA)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- CLI RestoreCommand passed wrong arguments (filepath instead of record ID)
|
||||||
|
- JPA path traversal: reject `../` in archive entry paths
|
||||||
|
- S3Uploader OOM: streaming upload instead of file_get_contents
|
||||||
|
- DatabaseDumper OOM: streaming to file instead of in-memory string
|
||||||
|
- AkeebaImporter: removed unserialize() (PHP object injection risk)
|
||||||
|
- BackupTable: delete DB row before file (prevents data loss)
|
||||||
|
- RestoreEngine: staging path sanitized with preg_replace
|
||||||
|
- API profiles: sensitive fields masked with `***`
|
||||||
|
- Webcron: missing return after sendJsonResponse on auth failure
|
||||||
|
- loadFormData(): cast array to object (PHP 8.x TypeError fix)
|
||||||
|
- MokoRestore data-only mode: uses REPLACE INTO for existing rows
|
||||||
|
- Plaintext archive deleted on encryption failure
|
||||||
|
- TarGzArchiver: intermediate .tar cleaned in finally block
|
||||||
|
- Install script: single-line comments converted to block comments
|
||||||
|
- Orphaned root-level webservices plugin files removed
|
||||||
|
- include_mokorestore column: TINYINT changed to VARCHAR(20)
|
||||||
|
- Snapshot fields_values: scoped dump and restore to com_content.article (previously destroyed values for contacts, users, etc.)
|
||||||
|
- Run Backup button: accept CSRF token from GET (fixes "token did not match" on profile edit)
|
||||||
|
- SFTP fields: moved into remote fieldset for showon visibility; removed required attr that blocked non-SFTP saves
|
||||||
|
- Script.php merge conflict markers resolved
|
||||||
|
|
||||||
|
## [01.24.00] — 2026-06-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62)
|
- Initial release: full-site backup and restore for Joomla 6
|
||||||
- Email/ntfy notifications for site restores and snapshot create/restore operations (#60)
|
- Database, files, and configuration backup
|
||||||
- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56)
|
- ZIP and tar.gz archive formats with AES-256 encryption
|
||||||
|
- Differential backups based on file manifests
|
||||||
## [01.31.00] --- 2026-06-22
|
- FTP/FTPS, S3, Google Drive remote storage
|
||||||
|
- MokoRestore standalone restore wizard
|
||||||
## [01.31.00] --- 2026-06-22
|
- CLI backup and restore commands
|
||||||
|
- REST API for remote management
|
||||||
### Added
|
- Scheduled tasks via com_scheduler
|
||||||
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
|
- Email and ntfy push notifications
|
||||||
- Automatic archive integrity verification after backup creation (#65)
|
- Per-profile retention, exclusions, and notifications
|
||||||
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
|
- Akeeba Backup migration tool
|
||||||
|
- Admin dashboard with system health checks
|
||||||
## [01.30.00] --- 2026-06-22
|
|
||||||
|
|
||||||
## [01.30.00] --- 2026-06-22
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
|
|
||||||
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
|
|
||||||
|
|||||||
@@ -1,50 +1,80 @@
|
|||||||
# MokoSuiteBackup
|
# MokoSuiteBackup
|
||||||
|
|
||||||
<!-- VERSION: 01.32.00 -->
|
|
||||||
|
|
||||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||||
|
|
||||||
## Overview
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
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.
|
| **Package** | `pkg_mokosuitebackup` |
|
||||||
|
| **Type** | Joomla Package (8 sub-extensions) |
|
||||||
|
| **Joomla** | 6.x+ |
|
||||||
|
| **PHP** | 8.1+ |
|
||||||
|
| **License** | GPL-3.0-or-later |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Full site backup (database + files + configuration)
|
### Backup
|
||||||
- Database-only backup mode
|
- Full site, database-only, files-only, and differential backup modes
|
||||||
- Files-only backup mode
|
- Pre-flight validation — checks directory, disk space, extensions, credentials before starting
|
||||||
- Multiple backup profiles with independent configurations
|
- Auto-verify archive integrity after creation
|
||||||
- File and directory exclusion filters
|
- Stepped AJAX engine prevents timeout on shared hosting
|
||||||
- Table exclusion filters for database backups
|
- AES-256 ZIP encryption with configurable password
|
||||||
- Step-based backup engine (avoids PHP timeout on large sites)
|
- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.)
|
||||||
- CLI script for cron/scheduled backups
|
- Data sanitization — optionally clear user passwords, emails, and sessions in backup
|
||||||
- REST API (Joomla Web Services) for remote management
|
|
||||||
- Backup record management (list, download, delete)
|
### Content Snapshots
|
||||||
- Automatic old backup cleanup (configurable retention)
|
- Lightweight JSON snapshots of articles, categories, and modules
|
||||||
- Admin dashboard with backup history and storage usage
|
- 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
|
## 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
|
2. Joomla Administrator > Extensions > Install
|
||||||
3. System plugin enabled automatically on install
|
3. Components > MokoSuiteBackup > Dashboard
|
||||||
|
|
||||||
## Configuration
|
## Documentation
|
||||||
|
|
||||||
- **Component**: Administrator > Components > MokoSuiteBackup
|
See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference.
|
||||||
- **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
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!DOCTYPE html><title></title>
|
|
||||||
@@ -12,5 +12,8 @@
|
|||||||
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
<action name="mokosuitebackup.backup.download" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_DOWNLOAD" />
|
||||||
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
<action name="mokosuitebackup.backup.restore" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_RESTORE" />
|
||||||
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
<action name="mokosuitebackup.snapshot.manage" title="COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE" />
|
||||||
|
<action name="mokosuitebackup.backup.purge" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE" />
|
||||||
|
<action name="mokosuitebackup.backup.compare" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE" />
|
||||||
|
<action name="mokosuitebackup.backup.browse" title="COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE" />
|
||||||
</section>
|
</section>
|
||||||
</access>
|
</access>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class BackupsController extends ApiController
|
|||||||
// Strip sensitive credentials before serialization
|
// Strip sensitive credentials before serialization
|
||||||
$sensitiveFields = [
|
$sensitiveFields = [
|
||||||
'ftp_password', 'ftp_username',
|
'ftp_password', 'ftp_username',
|
||||||
|
'sftp_password', 'sftp_key_data', 'sftp_passphrase',
|
||||||
's3_access_key', 's3_secret_key',
|
's3_access_key', 's3_secret_key',
|
||||||
'gdrive_client_secret', 'gdrive_refresh_token',
|
'gdrive_client_secret', 'gdrive_refresh_token',
|
||||||
'encryption_password', 'ntfy_token',
|
'encryption_password', 'ntfy_token',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SnapshotsController extends ApiController
|
|||||||
*/
|
*/
|
||||||
public function displayList(): static
|
public function displayList(): static
|
||||||
{
|
{
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->app->setHeader('status', 403);
|
$this->app->setHeader('status', 403);
|
||||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||||
$this->app->close();
|
$this->app->close();
|
||||||
@@ -250,7 +250,7 @@ class SnapshotsController extends ApiController
|
|||||||
*/
|
*/
|
||||||
public function download(): static
|
public function download(): static
|
||||||
{
|
{
|
||||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->app->setHeader('status', 403);
|
$this->app->setHeader('status', 403);
|
||||||
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
echo json_encode(['errors' => [['title' => 'Access denied']]]);
|
||||||
$this->app->close();
|
$this->app->close();
|
||||||
|
|||||||
@@ -39,6 +39,73 @@
|
|||||||
</field>
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="defaults" label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS">
|
||||||
|
<field
|
||||||
|
name="default_archive_format"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC"
|
||||||
|
default="zip"
|
||||||
|
>
|
||||||
|
<option value="zip">ZIP</option>
|
||||||
|
<option value="tar.gz">tar.gz</option>
|
||||||
|
<option value="7z">7z</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_mokorestore"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC"
|
||||||
|
default="0"
|
||||||
|
>
|
||||||
|
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||||
|
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||||
|
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_passwords"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_emails"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="default_sanitize_sessions"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="log_retention_days"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC"
|
||||||
|
default="90"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
<fieldset name="webcron" label="COM_MOKOJOOMBACKUP_CONFIG_WEBCRON">
|
||||||
<field
|
<field
|
||||||
name="webcron_secret"
|
name="webcron_secret"
|
||||||
@@ -172,6 +239,32 @@
|
|||||||
</field>
|
</field>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="ntfy" label="COM_MOKOJOOMBACKUP_CONFIG_NTFY">
|
||||||
|
<field
|
||||||
|
name="ntfy_server"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC"
|
||||||
|
default="https://ntfy.sh"
|
||||||
|
filter="url"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="ntfy_topic"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC"
|
||||||
|
default=""
|
||||||
|
filter="string"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="ntfy_token"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC"
|
||||||
|
default=""
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
<fieldset name="permissions" label="JCONFIG_PERMISSIONS_LABEL"
|
||||||
description="JCONFIG_PERMISSIONS_DESC">
|
description="JCONFIG_PERMISSIONS_DESC">
|
||||||
<field
|
<field
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
>
|
>
|
||||||
<option value="zip">ZIP</option>
|
<option value="zip">ZIP</option>
|
||||||
<option value="tar.gz">tar.gz</option>
|
<option value="tar.gz">tar.gz</option>
|
||||||
|
<option value="7z">COM_MOKOJOOMBACKUP_FORMAT_7Z</option>
|
||||||
</field>
|
</field>
|
||||||
<field
|
<field
|
||||||
name="compression_level"
|
name="compression_level"
|
||||||
@@ -72,23 +73,25 @@
|
|||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="archive_name_format"
|
name="archive_name_format"
|
||||||
type="text"
|
type="PlaceholderText"
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||||
default="[host]_[datetime]_profile[profile_id]"
|
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||||
maxlength="512"
|
maxlength="512"
|
||||||
hint="[host]_[datetime]_profile[profile_id]"
|
hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
|
||||||
|
placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
|
||||||
|
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||||
/>
|
/>
|
||||||
<field
|
<field
|
||||||
name="include_mokorestore"
|
name="include_mokorestore"
|
||||||
type="radio"
|
type="list"
|
||||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||||
default="0"
|
default="0"
|
||||||
class="btn-group"
|
|
||||||
>
|
>
|
||||||
<option value="1">JYES</option>
|
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||||
<option value="0">JNO</option>
|
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||||
|
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||||
</field>
|
</field>
|
||||||
<field
|
<field
|
||||||
name="encryption_password"
|
name="encryption_password"
|
||||||
@@ -99,6 +102,54 @@
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
|
||||||
|
<field
|
||||||
|
name="sanitize_passwords"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="preserve_super_admin"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
showon="sanitize_passwords:1"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sanitize_emails"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
|
||||||
|
default="0"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sanitize_sessions"
|
||||||
|
type="radio"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
|
||||||
|
default="1"
|
||||||
|
class="btn-group"
|
||||||
|
>
|
||||||
|
<option value="1">JYES</option>
|
||||||
|
<option value="0">JNO</option>
|
||||||
|
</field>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
|
||||||
<field
|
<field
|
||||||
name="id"
|
name="id"
|
||||||
@@ -151,6 +202,13 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
<fieldset name="remote" label="COM_MOKOJOOMBACKUP_FIELDSET_REMOTE">
|
||||||
|
<field
|
||||||
|
name="remote_legacy_note"
|
||||||
|
type="note"
|
||||||
|
label=""
|
||||||
|
description="COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE"
|
||||||
|
class="alert alert-info small"
|
||||||
|
/>
|
||||||
<field
|
<field
|
||||||
name="remote_storage"
|
name="remote_storage"
|
||||||
type="list"
|
type="list"
|
||||||
@@ -159,7 +217,7 @@
|
|||||||
default="none"
|
default="none"
|
||||||
>
|
>
|
||||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||||
</field>
|
</field>
|
||||||
@@ -174,6 +232,81 @@
|
|||||||
<option value="1">JYES</option>
|
<option value="1">JYES</option>
|
||||||
<option value="0">JNO</option>
|
<option value="0">JNO</option>
|
||||||
</field>
|
</field>
|
||||||
|
|
||||||
|
<!-- SFTP fields (shown when remote_storage = sftp) -->
|
||||||
|
<field
|
||||||
|
name="sftp_host"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_port"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC"
|
||||||
|
default="22"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_username"
|
||||||
|
type="text"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_auth_type"
|
||||||
|
type="list"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
|
||||||
|
default="key"
|
||||||
|
showon="remote_storage:sftp"
|
||||||
|
>
|
||||||
|
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
|
||||||
|
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
|
||||||
|
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
|
||||||
|
</field>
|
||||||
|
<field
|
||||||
|
name="sftp_password"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:password"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_key_data"
|
||||||
|
type="SshKey"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
||||||
|
filter="raw"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
|
||||||
|
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_passphrase"
|
||||||
|
type="password"
|
||||||
|
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
|
||||||
|
maxlength="255"
|
||||||
|
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="sftp_path"
|
||||||
|
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>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
<fieldset name="retention" label="COM_MOKOJOOMBACKUP_FIELDSET_RETENTION">
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions"
|
|||||||
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
|
COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site"
|
||||||
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS="Content Snapshots"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL="View All"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT="Latest"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS="No snapshots yet. Create one from the Content Snapshots view."
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile"
|
||||||
|
COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)"
|
||||||
|
|
||||||
; Backups view
|
; Backups view
|
||||||
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records"
|
||||||
@@ -44,6 +50,22 @@ COM_MOKOJOOMBACKUP_DOWNLOAD="Download"
|
|||||||
; Backup detail view
|
; Backup detail view
|
||||||
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail"
|
||||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||||
|
; Backup comparison
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||||
@@ -56,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
|||||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||||
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
||||||
|
|
||||||
|
; Profile actions
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||||
|
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||||
|
|
||||||
; Table headings
|
; Table headings
|
||||||
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
||||||
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
||||||
@@ -91,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count"
|
|||||||
; Archive settings
|
; Archive settings
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file"
|
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="Compression Level"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower"
|
||||||
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)"
|
||||||
@@ -98,15 +127,29 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)"
|
|||||||
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)"
|
||||||
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
|
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups."
|
COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="AES-256 encryption password. Leave blank for no encryption. Required to restore."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Where backups are stored. Use placeholders like [HOME]/backups for portability. Click the ? icon for full documentation."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
|
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template (without extension). Click the placeholder buttons below to insert tokens."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="None: no restore script. Wrapped: bundled inside the ZIP. Standalone: separate restore.php file (ideal for remote servers)."
|
||||||
|
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||||
|
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||||
|
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||||
|
|
||||||
|
; Data Sanitization
|
||||||
|
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace password hashes with invalid values. Users must reset passwords after restore. For demos, staging, or GDPR."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace emails with dummy values. Prevents accidental emails from cloned sites. Super admin preserved if enabled above."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude session data. Logs out all users on restore, prevents session hijacking. Enabled by default."
|
||||||
|
|
||||||
; Exclusion filter fields
|
; Exclusion filter fields
|
||||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||||
@@ -220,7 +263,35 @@ COM_MOKOJOOMBACKUP_VERIFY_FAILED="INTEGRITY CHECK FAILED — archive has been mo
|
|||||||
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
COM_MOKOJOOMBACKUP_VERIFY_NO_CHECKSUM="No checksum stored for this backup. Only backups created after this update can be verified."
|
||||||
|
|
||||||
; S3 storage
|
; S3 storage
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)"
|
||||||
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
COM_MOKOJOOMBACKUP_REMOTE_S3="Amazon S3 / S3-Compatible"
|
||||||
|
|
||||||
|
; SFTP fields
|
||||||
|
COM_MOKOJOOMBACKUP_FIELDSET_SFTP="SFTP Settings"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST="SFTP Host"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST_DESC="SFTP server hostname or IP address"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT="SFTP Port"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only. Leave blank for password auth."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
|
||||||
|
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
||||||
|
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH_DESC="Directory on the remote server to upload backups to"
|
||||||
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
|
COM_MOKOJOOMBACKUP_FIELDSET_S3="S3 Storage Settings"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT="S3 Endpoint"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT_DESC="S3 API endpoint URL. Leave blank for AWS S3. For Wasabi, MinIO, Backblaze B2, enter their endpoint URL."
|
||||||
@@ -343,6 +414,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted."
|
|||||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted."
|
||||||
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
COM_MOKOJOOMBACKUP_SNAPSHOTS_DELETE_ERRORS="Failed to delete snapshot(s): %s"
|
||||||
|
|
||||||
|
; Component Options — Defaults
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULTS="Profile Defaults"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT="Default Archive Format"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_FORMAT_DESC="Archive format used when creating new profiles. Can be overridden per profile."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE="Default MokoRestore Mode"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_MOKORESTORE_DESC="MokoRestore mode for new profiles. None, Wrapped (inside ZIP), or Standalone (separate file)."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW="Default: Sanitize Passwords"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_PW_DESC="Whether new profiles should sanitize user passwords by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL="Default: Sanitize Emails"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_EMAIL_DESC="Whether new profiles should sanitize user emails by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS="Default: Clear Sessions"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_DEFAULT_SANITIZE_SESS_DESC="Whether new profiles should clear session data by default."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION="Log Retention (days)"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_LOG_RETENTION_DESC="Days to keep .log files alongside backup archives. Set to 0 for unlimited."
|
||||||
|
|
||||||
|
; Component Options — ntfy
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY="Push Notifications (ntfy)"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER="Global ntfy Server"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC="Default ntfy server URL. Per-profile settings override this."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC="Global ntfy Topic"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOPIC_DESC="Default ntfy topic for backup notifications. Per-profile settings override this."
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN="Global ntfy Token"
|
||||||
|
COM_MOKOJOOMBACKUP_CONFIG_NTFY_TOKEN_DESC="Default access token for private ntfy topics. Per-profile settings override this."
|
||||||
|
|
||||||
|
; ACL — additional actions
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE="Purge Old Backups"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_PURGE_DESC="Allows users to bulk-delete backups older than a specific date."
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE="Compare Backups"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_COMPARE_DESC="Allows users to compare two backup records side-by-side."
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE="Browse Archives"
|
||||||
|
COM_MOKOSUITEBACKUP_ACTION_BACKUP_BROWSE_DESC="Allows users to view file listings inside backup archives without extracting."
|
||||||
|
|
||||||
; Snapshot ACL
|
; Snapshot ACL
|
||||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE="Manage Snapshots"
|
||||||
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
COM_MOKOSUITEBACKUP_ACTION_SNAPSHOT_MANAGE_DESC="Allows users in this group to create and restore content snapshots. Snapshots only affect articles, categories, and modules — not the full site."
|
||||||
@@ -365,6 +468,42 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
|
|||||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||||
|
|
||||||
|
; Snapshot browse / detail view
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
|
||||||
|
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
|
||||||
|
COM_MOKOJOOMBACKUP_LOADING="Loading..."
|
||||||
|
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."
|
||||||
|
|
||||||
|
; Remote Destinations (multi-remote)
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS="Remote Destinations"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_ADD="Add Destination"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_EDIT="Edit Destination"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_ENABLED="Enabled"
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED="No remote destinations configured. Use 'Add Destination' to send backups to SFTP, S3, or Google Drive."
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE="Legacy single-remote fields below are hidden when remote destinations are configured above. Existing legacy settings continue to work as a fallback."
|
||||||
|
COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM="Are you sure you want to delete this remote destination?"
|
||||||
|
|
||||||
; Errors
|
; Errors
|
||||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
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."
|
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
|||||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||||
|
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||||
|
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||||
|
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
||||||
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||||
@@ -77,9 +81,38 @@ COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data"
|
|||||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
|
COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name"
|
||||||
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE="Browse Archive Contents"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_NAME="Name"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE="Size"
|
||||||
|
COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED="Compressed"
|
||||||
|
; Backup comparison
|
||||||
|
COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE="Compare"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TITLE="Backup Comparison"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_LOADING="Loading comparison..."
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FIELD="Field"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_BACKUP="Backup"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DELTA="Delta"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE="DB Size"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT="Files Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT="Tables Count"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_DURATION="Duration"
|
||||||
|
COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO="Please select exactly two backup records to compare."
|
||||||
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
COM_MOKOJOOMBACKUP_FIELD_CHECKSUM="SHA-256 Checksum"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
COM_MOKOJOOMBACKUP_FIELD_PATH="File Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path"
|
||||||
COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups"
|
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."
|
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">
|
<extension type="component" method="upgrade">
|
||||||
<name>MokoSuiteBackup</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
|
||||||
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
|
||||||
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
|
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
|
||||||
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
|
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
|
||||||
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
|
||||||
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
|
||||||
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
|
||||||
@@ -19,6 +19,14 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
`ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||||
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
`ftp_passive` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
`ftp_ssl` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||||
|
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||||
|
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_key_data` MEDIUMTEXT,
|
||||||
|
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||||
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
`gdrive_client_id` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
`gdrive_client_secret` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
`gdrive_refresh_token` VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
@@ -31,7 +39,11 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
|||||||
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
`s3_path` VARCHAR(512) NOT NULL DEFAULT '/backups',
|
||||||
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||||
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
|
||||||
`include_mokorestore` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Include MokoRestore standalone restore script in archive',
|
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
|
||||||
|
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
|
||||||
|
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
|
||||||
|
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
|
||||||
|
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
|
||||||
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
|
||||||
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
|
||||||
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
@@ -95,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` (
|
|||||||
KEY `idx_created` (`created`)
|
KEY `idx_created` (`created`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||||
|
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
|
||||||
|
`config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings',
|
||||||
|
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_profile` (`profile_id`),
|
||||||
|
KEY `idx_enabled` (`enabled`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
-- Insert default backup profile (IGNORE prevents duplicate key error on update)
|
||||||
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
INSERT IGNORE INTO `#__mokosuitebackup_profiles` (
|
||||||
`id`, `title`, `description`, `backup_type`,
|
`id`, `title`, `description`, `backup_type`,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_records`;
|
||||||
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`;
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
|
|||||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
|
||||||
|
|
||||||
-- Add archive_name_format column with placeholder support
|
-- Add archive_name_format column with placeholder support
|
||||||
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- MokoSuiteBackup 01.35.00 — SFTP support with key file storage
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sftp_host` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ftp_ssl`,
|
||||||
|
ADD COLUMN `sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22 AFTER `sftp_host`,
|
||||||
|
ADD COLUMN `sftp_username` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_port`,
|
||||||
|
ADD COLUMN `sftp_password` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_username`,
|
||||||
|
ADD COLUMN `sftp_key_data` MEDIUMTEXT AFTER `sftp_password`,
|
||||||
|
ADD COLUMN `sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '' AFTER `sftp_key_data`,
|
||||||
|
ADD COLUMN `sftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups' AFTER `sftp_passphrase`;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.00 — Change include_mokorestore from TINYINT to VARCHAR
|
||||||
|
-- Needed to support 'standalone' value alongside 0/1
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
MODIFY COLUMN `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
|
||||||
|
|
||||||
|
UPDATE `#__mokosuitebackup_profiles` SET
|
||||||
|
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||||
|
`archive_name_format`,
|
||||||
|
'[host]', '[HOST]'),
|
||||||
|
'[site_name]', '[SITE_NAME]'),
|
||||||
|
'[datetime]', '[DATETIME]'),
|
||||||
|
'[date]', '[DATE]'),
|
||||||
|
'[time]', '[TIME]'),
|
||||||
|
'[year]', '[YEAR]'),
|
||||||
|
'[month]', '[MONTH]'),
|
||||||
|
'[day]', '[DAY]'),
|
||||||
|
'[hour]', '[HOUR]'),
|
||||||
|
'[minute]', '[MINUTE]'),
|
||||||
|
'[second]', '[SECOND]'),
|
||||||
|
'[profile_id]', '[PROFILE_ID]'),
|
||||||
|
'[profile_name]', '[PROFILE_NAME]'),
|
||||||
|
'[type]', '[TYPE]'),
|
||||||
|
'[random]', '[RANDOM]')
|
||||||
|
WHERE `archive_name_format` REGEXP '\\[[a-z]';
|
||||||
|
|
||||||
|
UPDATE `#__mokosuitebackup_profiles` SET
|
||||||
|
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
|
||||||
|
`backup_dir`,
|
||||||
|
'[host]', '[HOST]'),
|
||||||
|
'[site_name]', '[SITE_NAME]'),
|
||||||
|
'[date]', '[DATE]'),
|
||||||
|
'[year]', '[YEAR]'),
|
||||||
|
'[month]', '[MONTH]'),
|
||||||
|
'[day]', '[DAY]'),
|
||||||
|
'[profile_id]', '[PROFILE_ID]'),
|
||||||
|
'[profile_name]', '[PROFILE_NAME]')
|
||||||
|
WHERE `backup_dir` REGEXP '\\[[a-z]';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
|
||||||
|
|
||||||
|
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||||
|
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
|
||||||
|
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
|
||||||
|
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
|
||||||
|
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- MokoSuiteBackup 01.41.00 — Multi-remote storage destinations (#97)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` (
|
||||||
|
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`profile_id` INT(11) UNSIGNED NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive',
|
||||||
|
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`params` MEDIUMTEXT COMMENT 'JSON: type-specific settings',
|
||||||
|
`ordering` INT(11) NOT NULL DEFAULT 0,
|
||||||
|
`created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_profile` (`profile_id`),
|
||||||
|
KEY `idx_enabled` (`profile_id`, `enabled`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Migrate existing SFTP remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - SFTP'),
|
||||||
|
'sftp',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'host', `sftp_host`,
|
||||||
|
'port', `sftp_port`,
|
||||||
|
'username', `sftp_username`,
|
||||||
|
'auth_type', `sftp_auth_type`,
|
||||||
|
'password', `sftp_password`,
|
||||||
|
'key_data', COALESCE(`sftp_key_data`, ''),
|
||||||
|
'passphrase', `sftp_passphrase`,
|
||||||
|
'path', `sftp_path`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'sftp' AND `sftp_host` != '';
|
||||||
|
|
||||||
|
-- Migrate existing S3 remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - S3'),
|
||||||
|
's3',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'endpoint', `s3_endpoint`,
|
||||||
|
'region', `s3_region`,
|
||||||
|
'access_key', `s3_access_key`,
|
||||||
|
'secret_key', `s3_secret_key`,
|
||||||
|
'bucket', `s3_bucket`,
|
||||||
|
'path', `s3_path`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 's3' AND `s3_bucket` != '';
|
||||||
|
|
||||||
|
-- Migrate existing Google Drive remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - Google Drive'),
|
||||||
|
'google_drive',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'client_id', `gdrive_client_id`,
|
||||||
|
'client_secret', `gdrive_client_secret`,
|
||||||
|
'refresh_token', `gdrive_refresh_token`,
|
||||||
|
'folder_id', `gdrive_folder_id`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'google_drive' AND `gdrive_client_id` != '';
|
||||||
|
|
||||||
|
-- Migrate existing FTP remote configs into new table
|
||||||
|
INSERT INTO `#__mokosuitebackup_remotes` (`profile_id`, `title`, `type`, `enabled`, `params`, `ordering`, `created`)
|
||||||
|
SELECT
|
||||||
|
`id`,
|
||||||
|
CONCAT(`title`, ' - FTP'),
|
||||||
|
'ftp',
|
||||||
|
1,
|
||||||
|
JSON_OBJECT(
|
||||||
|
'host', `ftp_host`,
|
||||||
|
'port', `ftp_port`,
|
||||||
|
'username', `ftp_username`,
|
||||||
|
'password', `ftp_password`,
|
||||||
|
'path', `ftp_path`,
|
||||||
|
'passive', `ftp_passive`,
|
||||||
|
'ssl', `ftp_ssl`
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM `#__mokosuitebackup_profiles`
|
||||||
|
WHERE `remote_storage` = 'ftp' AND `ftp_host` != '';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
use Joomla\CMS\MVC\Controller\AdminController;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
||||||
|
|
||||||
@@ -34,7 +35,14 @@ class BackupsController extends AdminController
|
|||||||
*/
|
*/
|
||||||
public function start(): void
|
public function start(): void
|
||||||
{
|
{
|
||||||
$this->checkToken();
|
/* Accept token from both GET (profile Run button) and POST (backup form).
|
||||||
|
Joomla's checkToken() throws on failure, so try GET first. */
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->setMessage(Text::_('JINVALID_TOKEN_NOTICE'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
@@ -157,6 +165,88 @@ class BackupsController extends AdminController
|
|||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=backups', false));
|
$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('mokosuitebackup.backup.purge', '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.
|
* Verify integrity of a backup archive by re-computing SHA-256.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use Joomla\CMS\Factory;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\Controller\AdminController;
|
use Joomla\CMS\MVC\Controller\AdminController;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
|
||||||
|
|
||||||
@@ -106,6 +107,151 @@ class SnapshotsController extends AdminController
|
|||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browse articles inside a snapshot — returns JSON for AJAX modal.
|
||||||
|
*/
|
||||||
|
public function browse(): void
|
||||||
|
{
|
||||||
|
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('id', 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete') {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = file_get_contents($record->data_file);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||||
|
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$articles = [];
|
||||||
|
|
||||||
|
foreach ($data['tables']['#__content'] as $row) {
|
||||||
|
$articles[] = [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
'title' => $row['title'] ?? '',
|
||||||
|
'catid' => (int) ($row['catid'] ?? 0),
|
||||||
|
'state' => (int) ($row['state'] ?? 0),
|
||||||
|
'created' => $row['created'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->sendJson([
|
||||||
|
'error' => false,
|
||||||
|
'articles' => $articles,
|
||||||
|
'total' => count($articles),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore selected articles from a snapshot.
|
||||||
|
*/
|
||||||
|
public function restoreSelected(): void
|
||||||
|
{
|
||||||
|
$this->checkToken();
|
||||||
|
|
||||||
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->input->getInt('id', 0);
|
||||||
|
$articleIds = $this->input->get('article_ids', [], 'array');
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_RECORD'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
$this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED'), 'error');
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$engine = new SnapshotRestoreEngine();
|
||||||
|
$result = $engine->restoreSelectedArticles($id, $articleIds);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$this->setMessage($result['message']);
|
||||||
|
} else {
|
||||||
|
$this->setMessage($result['message'], 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON response and close the application.
|
||||||
|
*/
|
||||||
|
private function sendJson(array $data, int $status = 200): void
|
||||||
|
{
|
||||||
|
$app = $this->app;
|
||||||
|
$app->setHeader('status', $status);
|
||||||
|
$app->setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
$app->setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
$app->sendHeaders();
|
||||||
|
|
||||||
|
echo json_encode($data);
|
||||||
|
|
||||||
|
$app->close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete snapshot records and their data files.
|
* Delete snapshot records and their data files.
|
||||||
*/
|
*/
|
||||||
@@ -113,7 +259,7 @@ class SnapshotsController extends AdminController
|
|||||||
{
|
{
|
||||||
$this->checkToken();
|
$this->checkToken();
|
||||||
|
|
||||||
if (!$this->app->getIdentity()->authorise('core.delete', 'com_mokosuitebackup')) {
|
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||||
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
$this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error');
|
||||||
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
$this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false));
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,14 @@ class BackupEngine
|
|||||||
$archiveFormat = $profile->archive_format ?? 'zip';
|
$archiveFormat = $profile->archive_format ?? 'zip';
|
||||||
$archiveName = '';
|
$archiveName = '';
|
||||||
$archiver = $this->createArchiver($archiveFormat);
|
$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();
|
$archiveExt = $archiver->getExtension();
|
||||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
|
||||||
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
|
||||||
|
|
||||||
if (empty($description)) {
|
if (empty($description)) {
|
||||||
@@ -137,7 +143,19 @@ class BackupEngine
|
|||||||
if ($profile->backup_type !== 'files') {
|
if ($profile->backup_type !== 'files') {
|
||||||
$this->log('Starting database dump...');
|
$this->log('Starting database dump...');
|
||||||
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
|
||||||
$dumper = new DatabaseDumper($excludeTables);
|
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
|
||||||
|
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
|
||||||
|
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
|
||||||
|
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
|
||||||
|
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
|
||||||
|
|
||||||
|
if ($sanitizePasswords) {
|
||||||
|
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sanitizeEmails) {
|
||||||
|
$this->log('User emails will be sanitized');
|
||||||
|
}
|
||||||
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
$dbSize = $dumper->dumpToFile($sqlTempFile);
|
||||||
$archiver->addFile($sqlTempFile, 'database.sql');
|
$archiver->addFile($sqlTempFile, 'database.sql');
|
||||||
$tablesCount = $dumper->getTablesCount();
|
$tablesCount = $dumper->getTablesCount();
|
||||||
@@ -216,12 +234,14 @@ class BackupEngine
|
|||||||
$encryptionPassword = $profile->encryption_password ?? '';
|
$encryptionPassword = $profile->encryption_password ?? '';
|
||||||
|
|
||||||
if (!empty($encryptionPassword)) {
|
if (!empty($encryptionPassword)) {
|
||||||
if ($archiveFormat !== 'zip') {
|
if ($archiveFormat === 'zip') {
|
||||||
$this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption');
|
|
||||||
} else {
|
|
||||||
$this->log('Encrypting archive with AES-256...');
|
$this->log('Encrypting archive with AES-256...');
|
||||||
$this->encryptArchive($archivePath, $encryptionPassword);
|
$this->encryptArchive($archivePath, $encryptionPassword);
|
||||||
$this->log('Archive encrypted');
|
$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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,66 +257,118 @@ class BackupEngine
|
|||||||
$this->verifyArchive($archivePath, $profile->backup_type);
|
$this->verifyArchive($archivePath, $profile->backup_type);
|
||||||
$this->log('Archive integrity verified');
|
$this->log('Archive integrity verified');
|
||||||
|
|
||||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
// Step 2.5: MokoRestore script (if enabled)
|
||||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||||
|
$restoreScriptPath = '';
|
||||||
|
|
||||||
if ($includeMokoRestore) {
|
if ($mokoRestoreMode === '1') {
|
||||||
|
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
||||||
$this->log('Wrapping with MokoRestore script...');
|
$this->log('Wrapping with MokoRestore script...');
|
||||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||||
|
|
||||||
// Replace the original archive with the wrapped one
|
|
||||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||||
}
|
}
|
||||||
rename($mokoRestorePath, $archivePath);
|
rename($mokoRestorePath, $archivePath);
|
||||||
$totalSize = filesize($archivePath);
|
$totalSize = filesize($archivePath);
|
||||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||||
// Recompute checksum for the final wrapped archive
|
|
||||||
$checksum = hash_file('sha256', $archivePath);
|
$checksum = hash_file('sha256', $archivePath);
|
||||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||||
|
} elseif ($mokoRestoreMode === 'standalone') {
|
||||||
|
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
||||||
|
$this->log('Generating standalone restore.php...');
|
||||||
|
$restoreScriptPath = $this->backupDir . '/restore.php';
|
||||||
|
MokoRestore::generateStandalone($restoreScriptPath);
|
||||||
|
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Step 3: Remote upload (if configured)
|
/* Step 3: Remote upload — iterate all enabled destinations */
|
||||||
// Wrapped in its own try-catch so a remote failure does not mark
|
$remotes = $this->loadRemoteDestinations($db, $profileId);
|
||||||
// the entire backup as failed — the local archive is preserved.
|
|
||||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
|
||||||
|
|
||||||
if ($remoteStorage !== 'none') {
|
if (!empty($remotes)) {
|
||||||
try {
|
foreach ($remotes as $remote) {
|
||||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
try {
|
||||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
$this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...');
|
||||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
$params = json_decode($remote->params, true) ?: [];
|
||||||
|
$uploader = $this->createUploaderFromParams($remote->type, $params);
|
||||||
|
$result = $uploader->upload($archivePath, $archiveName);
|
||||||
|
|
||||||
if ($uploadResult['success']) {
|
if ($result['success']) {
|
||||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
$remoteFilename = $result['remote_path'] ?? $archiveName;
|
||||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
$this->log(' Upload complete: ' . $result['message']);
|
||||||
|
|
||||||
// Delete local copy if configured
|
/* Upload standalone restore.php if in standalone mode */
|
||||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
@unlink($archivePath);
|
$uploader->upload($restoreScriptPath, 'restore.php');
|
||||||
$this->log('Local copy removed (remote_keep_local = off)');
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
}
|
}
|
||||||
} else {
|
} catch (\Throwable $e) {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
$this->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete local copy only when ALL remotes succeeded and profile says so */
|
||||||
|
if (!$uploadFailed && empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
|
@unlink($archivePath);
|
||||||
|
$this->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* Backward-compat: fall back to legacy single-remote column */
|
||||||
|
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||||
|
|
||||||
|
if ($remoteStorage !== 'none') {
|
||||||
|
try {
|
||||||
|
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||||
|
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||||
|
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||||
|
|
||||||
|
if ($uploadResult['success']) {
|
||||||
|
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||||
|
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||||
|
|
||||||
|
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||||
|
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||||
|
$this->log('Uploading standalone restore.php...');
|
||||||
|
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||||
|
|
||||||
|
if ($restoreUpload['success']) {
|
||||||
|
$this->log('Standalone restore.php uploaded');
|
||||||
|
} else {
|
||||||
|
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete local copy if configured
|
||||||
|
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
|
@unlink($archivePath);
|
||||||
|
$this->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
||||||
|
$this->log('Local backup is preserved.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||||
$this->log('Local backup is preserved.');
|
$this->log('Local backup is preserved.');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$uploadFailed = true;
|
|
||||||
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
|
||||||
$this->log('Local backup is preserved.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write log file alongside the archive
|
// Write log file alongside the archive
|
||||||
$logContent = implode("\n", $this->log);
|
$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) {
|
if (@file_put_contents($logPath, $logContent) === false) {
|
||||||
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
error_log('MokoSuiteBackup: Could not write log file: ' . $logPath);
|
||||||
}
|
}
|
||||||
@@ -442,23 +514,80 @@ class BackupEngine
|
|||||||
return match ($format) {
|
return match ($format) {
|
||||||
'zip' => new ZipArchiver(),
|
'zip' => new ZipArchiver(),
|
||||||
'tar.gz' => new TarGzArchiver(),
|
'tar.gz' => new TarGzArchiver(),
|
||||||
|
'7z' => new SevenZipArchiver(),
|
||||||
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
default => throw new \InvalidArgumentException('Unknown archive format: ' . $format),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the appropriate remote uploader based on the storage type.
|
* Create the appropriate remote uploader based on the storage type.
|
||||||
|
* Legacy method — used by backward-compat fallback when remotes table
|
||||||
|
* does not exist.
|
||||||
*/
|
*/
|
||||||
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
private function createUploader(string $type, object $profile): RemoteUploaderInterface
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
'ftp' => new FtpUploader($profile),
|
'ftp' => new FtpUploader($profile),
|
||||||
|
'sftp' => new SftpUploader($profile),
|
||||||
'google_drive' => new GoogleDriveUploader($profile),
|
'google_drive' => new GoogleDriveUploader($profile),
|
||||||
's3' => new S3Uploader($profile),
|
's3' => new S3Uploader($profile),
|
||||||
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||||
|
*
|
||||||
|
* Builds a fake profile-like object from the params array so the existing
|
||||||
|
* uploader constructors work without modification.
|
||||||
|
*
|
||||||
|
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||||
|
* @param array $params Key-value params decoded from the remote's JSON
|
||||||
|
*
|
||||||
|
* @return RemoteUploaderInterface
|
||||||
|
*/
|
||||||
|
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||||
|
{
|
||||||
|
$fake = (object) $params;
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'ftp' => new FtpUploader($fake),
|
||||||
|
'sftp' => new SftpUploader($fake),
|
||||||
|
'google_drive' => new GoogleDriveUploader($fake),
|
||||||
|
's3' => new S3Uploader($fake),
|
||||||
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load enabled remote destinations for a profile from the remotes table.
|
||||||
|
*
|
||||||
|
* Returns an empty array when the table does not exist (pre-migration)
|
||||||
|
* so the caller can fall back to the legacy single-remote column.
|
||||||
|
*
|
||||||
|
* @param object $db Database driver
|
||||||
|
* @param int $profileId Profile ID
|
||||||
|
*
|
||||||
|
* @return object[] Array of remote destination rows
|
||||||
|
*/
|
||||||
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the file manifest from the most recent full backup for this profile.
|
* Load the file manifest from the most recent full backup for this profile.
|
||||||
* Used by differential backups to determine which files changed.
|
* Used by differential backups to determine which files changed.
|
||||||
@@ -546,6 +675,13 @@ class BackupEngine
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7z verification via CLI
|
||||||
|
if ($extension === '7z') {
|
||||||
|
$this->verify7zArchive($archivePath);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ZIP verification
|
// ZIP verification
|
||||||
$zip = new \ZipArchive();
|
$zip = new \ZipArchive();
|
||||||
|
|
||||||
@@ -607,6 +743,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.
|
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,12 +27,35 @@ class DatabaseDumper
|
|||||||
|
|
||||||
private int $tablesCount = 0;
|
private int $tablesCount = 0;
|
||||||
|
|
||||||
|
/** @var bool Whether to sanitize user passwords */
|
||||||
|
private bool $sanitizePasswords = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to preserve super admin password when sanitizing */
|
||||||
|
private bool $preserveSuperAdmin = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to sanitize user emails */
|
||||||
|
private bool $sanitizeEmails = false;
|
||||||
|
|
||||||
|
/** @var bool Whether to clear session data */
|
||||||
|
private bool $sanitizeSessions = false;
|
||||||
|
|
||||||
|
/** Known invalid bcrypt hash used for sanitized passwords */
|
||||||
|
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
* @param array $excludeTables Table names to exclude (with #__ prefix).
|
||||||
* Supports suffixes: :data-only, :structure-only.
|
* @param bool $sanitizePasswords Replace user password hashes with invalid value
|
||||||
* No suffix = exclude both (backward compatible).
|
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
|
||||||
|
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
|
||||||
|
* @param bool $sanitizeSessions Skip session table data entirely
|
||||||
*/
|
*/
|
||||||
public function __construct(array $excludeTables = [])
|
public function __construct(
|
||||||
|
array $excludeTables = [],
|
||||||
|
bool $sanitizePasswords = false,
|
||||||
|
bool $preserveSuperAdmin = false,
|
||||||
|
bool $sanitizeEmails = false,
|
||||||
|
bool $sanitizeSessions = false
|
||||||
|
)
|
||||||
{
|
{
|
||||||
foreach ($excludeTables as $entry) {
|
foreach ($excludeTables as $entry) {
|
||||||
if (str_ends_with($entry, ':data-only')) {
|
if (str_ends_with($entry, ':data-only')) {
|
||||||
@@ -43,6 +66,16 @@ class DatabaseDumper
|
|||||||
$this->excludeBoth[] = $entry;
|
$this->excludeBoth[] = $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->sanitizePasswords = $sanitizePasswords;
|
||||||
|
$this->preserveSuperAdmin = $preserveSuperAdmin;
|
||||||
|
$this->sanitizeEmails = $sanitizeEmails;
|
||||||
|
$this->sanitizeSessions = $sanitizeSessions;
|
||||||
|
|
||||||
|
/* If session sanitization is on, auto-exclude session table data */
|
||||||
|
if ($sanitizeSessions) {
|
||||||
|
$this->excludeDataOnly[] = '#__session';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,6 +187,7 @@ class DatabaseDumper
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$this->sanitizeRow($row, $abstractName, $db);
|
||||||
$values = [];
|
$values = [];
|
||||||
|
|
||||||
foreach ($row as $value) {
|
foreach ($row as $value) {
|
||||||
@@ -326,6 +360,7 @@ class DatabaseDumper
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
|
$this->sanitizeRow($row, $abstractName, $db);
|
||||||
$values = [];
|
$values = [];
|
||||||
|
|
||||||
foreach ($row as $value) {
|
foreach ($row as $value) {
|
||||||
@@ -351,6 +386,86 @@ class DatabaseDumper
|
|||||||
return filesize($filePath) ?: 0;
|
return filesize($filePath) ?: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a row if it belongs to the users table and sanitization is enabled.
|
||||||
|
*
|
||||||
|
* Replaces the password column with an invalid hash so the backup
|
||||||
|
* cannot be used to extract user credentials.
|
||||||
|
*/
|
||||||
|
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
|
||||||
|
{
|
||||||
|
if ($abstractTable !== '#__users') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
|
||||||
|
$userId = (int) $row['id'];
|
||||||
|
|
||||||
|
/* Preserve super admin emails if preserving super admin */
|
||||||
|
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
|
||||||
|
$row['email'] = 'user' . $userId . '@sanitized.example.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->sanitizePasswords || !isset($row['password'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->preserveSuperAdmin && isset($row['id'])) {
|
||||||
|
if ($this->isSuperAdmin((int) $row['id'], $db)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['password'] = self::SANITIZED_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user ID belongs to the Super Users group (group_id = 8).
|
||||||
|
*/
|
||||||
|
private function isSuperAdmin(int $userId, object $db): bool
|
||||||
|
{
|
||||||
|
static $superAdminIds = null;
|
||||||
|
|
||||||
|
if ($superAdminIds === null) {
|
||||||
|
$prefix = $db->getPrefix();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->select('DISTINCT ' . $db->quoteName('user_id'))
|
||||||
|
->from($db->quoteName($prefix . 'user_usergroup_map'))
|
||||||
|
->where($db->quoteName('group_id') . ' = 8')
|
||||||
|
);
|
||||||
|
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$superAdminIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($userId, $superAdminIds, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if passwords were sanitized (for use by callers to log the action).
|
||||||
|
*/
|
||||||
|
public function isPasswordSanitizationEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->sanitizePasswords;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sentinel hash used for sanitized passwords.
|
||||||
|
*/
|
||||||
|
public static function getSanitizedHash(): string
|
||||||
|
{
|
||||||
|
return self::SANITIZED_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTablesCount(): int
|
public function getTablesCount(): int
|
||||||
{
|
{
|
||||||
return $this->tablesCount;
|
return $this->tablesCount;
|
||||||
|
|||||||
@@ -54,6 +54,191 @@ class MokoRestore
|
|||||||
return $outputPath;
|
return $outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the standalone restore.php script as a separate file.
|
||||||
|
*
|
||||||
|
* Unlike the wrapped version, this script scans its own directory
|
||||||
|
* for ZIP files and lets the user choose which one to restore from.
|
||||||
|
*
|
||||||
|
* @param string $outputPath Where to write restore.php
|
||||||
|
*
|
||||||
|
* @return string Path to the generated script
|
||||||
|
*/
|
||||||
|
public static function generateStandalone(string $outputPath): string
|
||||||
|
{
|
||||||
|
$script = self::generateStandaloneScript();
|
||||||
|
|
||||||
|
if (file_put_contents($outputPath, $script) === false) {
|
||||||
|
throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the standalone script content that scans for ZIPs.
|
||||||
|
*/
|
||||||
|
private static function generateStandaloneScript(): string
|
||||||
|
{
|
||||||
|
/* Take the normal backend but replace the hardcoded BACKUP_FILE
|
||||||
|
with a directory scanner that finds ZIP files */
|
||||||
|
$php = self::generateBackend();
|
||||||
|
|
||||||
|
/* Replace the fixed BACKUP_FILE constant with dynamic scanner */
|
||||||
|
$php = str_replace(
|
||||||
|
"define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');",
|
||||||
|
"/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" .
|
||||||
|
"define('BACKUP_FILE', ''); /* placeholder — overridden per request */",
|
||||||
|
$php
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Inject the backup scanner function after the constants */
|
||||||
|
$scannerCode = <<<'SCANNER'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the restore directory for ZIP files that look like backups.
|
||||||
|
*/
|
||||||
|
function scanForBackups(): array
|
||||||
|
{
|
||||||
|
$dir = RESTORE_DIR;
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
foreach (glob($dir . '/*.zip') as $path) {
|
||||||
|
$name = basename($path);
|
||||||
|
|
||||||
|
/* Skip the restore script wrapper if present */
|
||||||
|
if ($name === 'restore.php') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'name' => $name,
|
||||||
|
'path' => $path,
|
||||||
|
'size' => filesize($path),
|
||||||
|
'date' => date('Y-m-d H:i:s', filemtime($path)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sort by modification time, newest first */
|
||||||
|
usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle backup file selection and set the working file.
|
||||||
|
*/
|
||||||
|
function getSelectedBackupFile(): string
|
||||||
|
{
|
||||||
|
if (!empty($_POST['backup_file'])) {
|
||||||
|
$selected = basename($_POST['backup_file']); /* sanitize — basename only */
|
||||||
|
$path = RESTORE_DIR . '/' . $selected;
|
||||||
|
|
||||||
|
if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-select if only one ZIP exists */
|
||||||
|
$backups = scanForBackups();
|
||||||
|
|
||||||
|
if (count($backups) === 1) {
|
||||||
|
return $backups[0]['path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
SCANNER;
|
||||||
|
|
||||||
|
/* Insert scanner after the opening PHP section but before the action handlers */
|
||||||
|
$php = str_replace(
|
||||||
|
"/* ── Action Handlers",
|
||||||
|
$scannerCode . "\n/* ── Action Handlers",
|
||||||
|
$php
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */
|
||||||
|
$php = str_replace(
|
||||||
|
'$zip->open(BACKUP_FILE)',
|
||||||
|
'$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)',
|
||||||
|
$php
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Modify the pre-checks to use getSelectedBackupFile() */
|
||||||
|
$php = str_replace(
|
||||||
|
"file_exists(BACKUP_FILE)",
|
||||||
|
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||||
|
$php
|
||||||
|
);
|
||||||
|
|
||||||
|
$html = self::generateFrontend();
|
||||||
|
|
||||||
|
/* Add backup file selector to the frontend before the extract step */
|
||||||
|
$selectorHtml = <<<'SELECTOR'
|
||||||
|
<!-- Backup File Selector (standalone mode) -->
|
||||||
|
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||||
|
<h2 class="mr-step-title">Select Backup File</h2>
|
||||||
|
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||||
|
<div id="mr-backup-list"></div>
|
||||||
|
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||||
|
var list = document.getElementById('mr-backup-list');
|
||||||
|
var hiddenInput = document.getElementById('mr-backup-file');
|
||||||
|
|
||||||
|
if (backups.length === 0) {
|
||||||
|
var alert = document.createElement('div');
|
||||||
|
alert.className = 'mr-alert mr-alert-danger';
|
||||||
|
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||||
|
list.appendChild(alert);
|
||||||
|
} else if (backups.length === 1) {
|
||||||
|
hiddenInput.value = backups[0].name;
|
||||||
|
var found = document.createElement('div');
|
||||||
|
found.className = 'mr-alert mr-alert-success';
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = backups[0].name;
|
||||||
|
found.appendChild(document.createTextNode('Found: '));
|
||||||
|
found.appendChild(strong);
|
||||||
|
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||||
|
list.appendChild(found);
|
||||||
|
} else {
|
||||||
|
var group = document.createElement('div');
|
||||||
|
group.className = 'mr-field-group';
|
||||||
|
backups.forEach(function(b) {
|
||||||
|
var label = document.createElement('label');
|
||||||
|
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||||
|
var radio = document.createElement('input');
|
||||||
|
radio.type = 'radio';
|
||||||
|
radio.name = 'backup_choice';
|
||||||
|
radio.value = b.name;
|
||||||
|
radio.style.marginRight = '8px';
|
||||||
|
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||||
|
label.appendChild(radio);
|
||||||
|
var nameStrong = document.createElement('strong');
|
||||||
|
nameStrong.textContent = b.name;
|
||||||
|
label.appendChild(nameStrong);
|
||||||
|
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||||
|
group.appendChild(label);
|
||||||
|
});
|
||||||
|
list.appendChild(group);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
SELECTOR;
|
||||||
|
|
||||||
|
/* Insert the selector before the extract step in the HTML */
|
||||||
|
$html = str_replace(
|
||||||
|
'<!-- Step: Extract -->',
|
||||||
|
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
return $php . $html;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the standalone restore.php script.
|
* Generate the standalone restore.php script.
|
||||||
*
|
*
|
||||||
@@ -191,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
|
|||||||
function handleAction(string $action, array $data): array
|
function handleAction(string $action, array $data): array
|
||||||
{
|
{
|
||||||
return match ($action) {
|
return match ($action) {
|
||||||
'preflight' => actionPreflight(),
|
'preflight' => actionPreflight(),
|
||||||
'extract' => actionExtract($data),
|
'extract' => actionExtract($data),
|
||||||
'testdb' => actionTestDb($data),
|
'scanTables' => actionScanTables(),
|
||||||
'database' => actionDatabase($data),
|
'testdb' => actionTestDb($data),
|
||||||
'config' => actionConfig($data),
|
'database' => actionDatabase($data),
|
||||||
'listAdmins' => actionListAdmins($data),
|
'config' => actionConfig($data),
|
||||||
'resetAdmin' => actionResetAdmin($data),
|
'listAdmins' => actionListAdmins($data),
|
||||||
'provision' => actionProvision($data),
|
'resetAdmin' => actionResetAdmin($data),
|
||||||
'cleanup' => actionCleanup(),
|
'postRestore' => actionPostRestore($data),
|
||||||
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
|
'detectSanitized' => detectSanitizedPasswords($data),
|
||||||
|
'provision' => actionProvision($data),
|
||||||
|
'cleanup' => actionCleanup(),
|
||||||
|
default => ['success' => false, 'message' => 'Unknown action: ' . $action],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,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
|
function actionTestDb(array $data): array
|
||||||
{
|
{
|
||||||
$host = $data['db_host'] ?? 'localhost';
|
$host = $data['db_host'] ?? 'localhost';
|
||||||
@@ -423,10 +670,27 @@ function actionDatabase(array $data): array
|
|||||||
// Replace abstract #__ prefix with the user's target prefix
|
// Replace abstract #__ prefix with the user's target prefix
|
||||||
$sql = str_replace('#__', $prefix, $sql);
|
$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);
|
$parts = explode(";\n", $sql);
|
||||||
$statements = 0;
|
$statements = 0;
|
||||||
$errors = 0;
|
$errors = 0;
|
||||||
$errorList = [];
|
$errorList = [];
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
foreach ($parts as $part) {
|
foreach ($parts as $part) {
|
||||||
$part = trim($part);
|
$part = trim($part);
|
||||||
@@ -435,6 +699,42 @@ function actionDatabase(array $data): array
|
|||||||
continue;
|
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 {
|
try {
|
||||||
$pdo->exec($part);
|
$pdo->exec($part);
|
||||||
$statements++;
|
$statements++;
|
||||||
@@ -449,11 +749,22 @@ function actionDatabase(array $data): array
|
|||||||
|
|
||||||
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
|
||||||
|
$msg = "Executed {$statements} statements";
|
||||||
|
|
||||||
|
if ($skipped > 0) {
|
||||||
|
$msg .= " ({$skipped} skipped)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors > 0) {
|
||||||
|
$msg .= " ({$errors} warnings)";
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => ($statements > 0 || $errors === 0),
|
'success' => ($statements > 0 || $errors === 0),
|
||||||
'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''),
|
'message' => $msg,
|
||||||
'statements' => $statements,
|
'statements' => $statements,
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
|
'skipped' => $skipped,
|
||||||
'errorList' => $errorList,
|
'errorList' => $errorList,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -804,6 +1115,128 @@ function actionResetAdmin(array $data): array
|
|||||||
return ['success' => true, 'message' => 'Admin password updated successfully'];
|
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
|
function actionProvision(array $data): array
|
||||||
{
|
{
|
||||||
$pdo = getDbConnection($data);
|
$pdo = getDbConnection($data);
|
||||||
@@ -1048,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
<div class="mr-steps" id="stepBar">
|
<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 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="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="3"><span class="mr-num">3</span>Tables</div>
|
||||||
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</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>Admin</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>Provisioning</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>Complete</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>
|
</div>
|
||||||
|
|
||||||
<!-- Step 0: Security Verification -->
|
<!-- Step 0: Security Verification -->
|
||||||
@@ -1107,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Database -->
|
<!-- Step 3: Table Conflict Resolution -->
|
||||||
<div class="mr-panel" id="panel3">
|
<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>
|
<h2>Database Configuration</h2>
|
||||||
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
|
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
|
||||||
<div class="mr-row">
|
<div class="mr-row">
|
||||||
@@ -1127,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-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
|
||||||
<div class="mr-status" id="dbStatus"></div>
|
<div class="mr-status" id="dbStatus"></div>
|
||||||
<div class="mr-actions">
|
<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>
|
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Site Configuration -->
|
<!-- Step 5: Site Configuration -->
|
||||||
<div class="mr-panel" id="panel4">
|
<div class="mr-panel" id="panel5">
|
||||||
<h2>Site Configuration</h2>
|
<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>
|
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security — enter the correct values for this server.</p>
|
||||||
|
|
||||||
@@ -1176,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
</div>
|
</div>
|
||||||
<div class="mr-status" id="configStatus"></div>
|
<div class="mr-status" id="configStatus"></div>
|
||||||
<div class="mr-actions">
|
<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>
|
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 5: Admin Password Reset -->
|
<!-- Step 6: Admin Password Reset -->
|
||||||
<div class="mr-panel" id="panel5">
|
<div class="mr-panel" id="panel6">
|
||||||
<h2>Super Admin Password</h2>
|
<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>
|
<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">
|
<div class="mr-field">
|
||||||
@@ -1195,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
</div>
|
</div>
|
||||||
<div class="mr-status" id="adminStatus"></div>
|
<div class="mr-status" id="adminStatus"></div>
|
||||||
<div class="mr-actions">
|
<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>
|
<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>
|
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 6: Client Provisioning -->
|
<!-- Step 7: Post-Restore Actions -->
|
||||||
<div class="mr-panel" id="panel6">
|
<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>
|
<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>
|
<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">
|
<ul class="mr-provision-list">
|
||||||
@@ -1219,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="mr-status" id="provisionStatus"></div>
|
<div class="mr-status" id="provisionStatus"></div>
|
||||||
<div class="mr-actions">
|
<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>
|
<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>
|
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 7: Complete -->
|
<!-- Step 9: Complete -->
|
||||||
<div class="mr-panel" id="panel7">
|
<div class="mr-panel" id="panel9">
|
||||||
<h2>Installation Complete</h2>
|
<h2>Installation Complete</h2>
|
||||||
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
|
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
|
||||||
<div class="mr-alert mr-alert-success">
|
<div class="mr-alert mr-alert-success">
|
||||||
@@ -1299,7 +1786,9 @@ function goStep(n) {
|
|||||||
else if (sn < n) s.classList.add('done');
|
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) {
|
function setStatus(id, msg, type) {
|
||||||
@@ -1436,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() {
|
function getDbParams() {
|
||||||
return {
|
return {
|
||||||
db_host: document.getElementById('dbHost').value,
|
db_host: document.getElementById('dbHost').value,
|
||||||
@@ -1462,7 +2055,12 @@ async function runDatabase() {
|
|||||||
log('Importing database...');
|
log('Importing database...');
|
||||||
|
|
||||||
dbConfig = getDbParams();
|
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%';
|
document.getElementById('dbProgress').style.width = '100%';
|
||||||
setBtnLoading(btn, false);
|
setBtnLoading(btn, false);
|
||||||
@@ -1470,17 +2068,20 @@ async function runDatabase() {
|
|||||||
if (r.success) {
|
if (r.success) {
|
||||||
setStatus('dbStatus', r.message, 'success');
|
setStatus('dbStatus', r.message, 'success');
|
||||||
log(r.message);
|
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) {
|
if (r.errorList && r.errorList.length > 0) {
|
||||||
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
|
r.errorList.forEach(function(e) { log(' Warning: ' + e); });
|
||||||
}
|
}
|
||||||
setTimeout(function() { goStep(4); }, 500);
|
setTimeout(function() { goStep(5); }, 500);
|
||||||
} else {
|
} else {
|
||||||
setStatus('dbStatus', r.message, 'error');
|
setStatus('dbStatus', r.message, 'error');
|
||||||
log('FAILED: ' + r.message);
|
log('FAILED: ' + r.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4
|
// Step 5
|
||||||
async function runConfig() {
|
async function runConfig() {
|
||||||
const btn = document.getElementById('btnConfig');
|
const btn = document.getElementById('btnConfig');
|
||||||
setBtnLoading(btn, true);
|
setBtnLoading(btn, true);
|
||||||
@@ -1501,14 +2102,14 @@ async function runConfig() {
|
|||||||
if (r.success) {
|
if (r.success) {
|
||||||
setStatus('configStatus', r.message, 'success');
|
setStatus('configStatus', r.message, 'success');
|
||||||
log(r.message);
|
log(r.message);
|
||||||
setTimeout(function() { goStep(5); }, 500);
|
setTimeout(function() { goStep(6); }, 500);
|
||||||
} else {
|
} else {
|
||||||
setStatus('configStatus', r.message, 'error');
|
setStatus('configStatus', r.message, 'error');
|
||||||
log('FAILED: ' + r.message);
|
log('FAILED: ' + r.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5
|
// Step 6
|
||||||
async function loadAdmins() {
|
async function loadAdmins() {
|
||||||
const sel = document.getElementById('adminSelect');
|
const sel = document.getElementById('adminSelect');
|
||||||
while (sel.firstChild) sel.removeChild(sel.firstChild);
|
while (sel.firstChild) sel.removeChild(sel.firstChild);
|
||||||
@@ -1553,20 +2154,65 @@ async function runResetAdmin() {
|
|||||||
if (r.success) {
|
if (r.success) {
|
||||||
setStatus('adminStatus', r.message, 'success');
|
setStatus('adminStatus', r.message, 'success');
|
||||||
log(r.message);
|
log(r.message);
|
||||||
setTimeout(function() { goStep(6); }, 500);
|
setTimeout(function() { goStep(7); }, 500);
|
||||||
} else {
|
} else {
|
||||||
setStatus('adminStatus', r.message, 'error');
|
setStatus('adminStatus', r.message, 'error');
|
||||||
log('FAILED: ' + r.message);
|
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() {
|
async function runProvision() {
|
||||||
const btn = document.getElementById('btnProvision');
|
const btn = document.getElementById('btnProvision');
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); });
|
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);
|
setBtnLoading(btn, true);
|
||||||
log('Running ' + tasks.length + ' provisioning tasks...');
|
log('Running ' + tasks.length + ' provisioning tasks...');
|
||||||
@@ -1579,14 +2225,14 @@ async function runProvision() {
|
|||||||
if (r.success) {
|
if (r.success) {
|
||||||
setStatus('provisionStatus', r.message, 'success');
|
setStatus('provisionStatus', r.message, 'success');
|
||||||
r.results.forEach(function(msg) { log(' ' + msg); });
|
r.results.forEach(function(msg) { log(' ' + msg); });
|
||||||
setTimeout(function() { goStep(7); }, 500);
|
setTimeout(function() { goStep(9); }, 500);
|
||||||
} else {
|
} else {
|
||||||
setStatus('provisionStatus', r.message, 'error');
|
setStatus('provisionStatus', r.message, 'error');
|
||||||
log('FAILED: ' + r.message);
|
log('FAILED: ' + r.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 7
|
// Step 9
|
||||||
async function runCleanup() {
|
async function runCleanup() {
|
||||||
log('Cleaning up restore files...');
|
log('Cleaning up restore files...');
|
||||||
const r = await post('cleanup');
|
const r = await post('cleanup');
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||||
* @license GNU General Public License version 3 or later; see LICENSE
|
* @license GNU General Public License version 3 or later; see LICENSE
|
||||||
*
|
*
|
||||||
* Resolves placeholders like [host], [date], [profile_name] in backup
|
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
|
||||||
* directory paths and archive filename formats.
|
* directory paths and archive filename formats.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,21 +24,21 @@ class PlaceholderResolver
|
|||||||
* Supported placeholders and their descriptions (for documentation).
|
* Supported placeholders and their descriptions (for documentation).
|
||||||
*/
|
*/
|
||||||
public const PLACEHOLDERS = [
|
public const PLACEHOLDERS = [
|
||||||
'[host]' => 'Server hostname',
|
'[HOST]' => 'Server hostname',
|
||||||
'[date]' => 'Date as Ymd (e.g. 20260604)',
|
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
|
||||||
'[time]' => 'Time as His (e.g. 143025)',
|
'[TIME]' => 'Time as His (e.g. 143025)',
|
||||||
'[datetime]' => 'Date and time as Ymd_His',
|
'[DATETIME]' => 'Date and time as Ymd_His',
|
||||||
'[year]' => 'Four-digit year',
|
'[YEAR]' => 'Four-digit year',
|
||||||
'[month]' => 'Two-digit month',
|
'[MONTH]' => 'Two-digit month',
|
||||||
'[day]' => 'Two-digit day',
|
'[DAY]' => 'Two-digit day',
|
||||||
'[hour]' => 'Two-digit hour (24h)',
|
'[HOUR]' => 'Two-digit hour (24h)',
|
||||||
'[minute]' => 'Two-digit minute',
|
'[MINUTE]' => 'Two-digit minute',
|
||||||
'[second]' => 'Two-digit second',
|
'[SECOND]' => 'Two-digit second',
|
||||||
'[profile_id]' => 'Backup profile ID',
|
'[PROFILE_ID]' => 'Backup profile ID',
|
||||||
'[profile_name]' => 'Profile title (sanitized)',
|
'[PROFILE_NAME]' => 'Profile title (sanitized)',
|
||||||
'[site_name]' => 'Joomla site name (sanitized)',
|
'[SITE_NAME]' => 'Joomla site name (sanitized)',
|
||||||
'[type]' => 'Backup type (full, database, files, differential)',
|
'[TYPE]' => 'Backup type (full, database, files, differential)',
|
||||||
'[random]' => 'Random 6-character hex string',
|
'[RANDOM]' => 'Random 6-character hex string',
|
||||||
'[DEFAULT_DIR]' => 'Default backup directory',
|
'[DEFAULT_DIR]' => 'Default backup directory',
|
||||||
'[HOME]' => 'Home directory of the PHP process owner',
|
'[HOME]' => 'Home directory of the PHP process owner',
|
||||||
];
|
];
|
||||||
@@ -51,7 +51,32 @@ class PlaceholderResolver
|
|||||||
public function __construct(object $profile)
|
public function __construct(object $profile)
|
||||||
{
|
{
|
||||||
$now = new \DateTimeImmutable('now');
|
$now = new \DateTimeImmutable('now');
|
||||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
|
||||||
|
/* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */
|
||||||
|
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawHost) || $rawHost === 'localhost') {
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
$liveSite = $app->get('live_site', '');
|
||||||
|
|
||||||
|
if (!empty($liveSite)) {
|
||||||
|
$parsed = parse_url($liveSite, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!empty($parsed)) {
|
||||||
|
$rawHost = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rawHost)) {
|
||||||
|
$rawHost = php_uname('n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
|
||||||
|
|
||||||
$siteName = '';
|
$siteName = '';
|
||||||
|
|
||||||
@@ -62,21 +87,21 @@ class PlaceholderResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->replacements = [
|
$this->replacements = [
|
||||||
'[host]' => $hostname,
|
'[HOST]' => $hostname,
|
||||||
'[date]' => $now->format('Ymd'),
|
'[DATE]' => $now->format('Ymd'),
|
||||||
'[time]' => $now->format('His'),
|
'[TIME]' => $now->format('His'),
|
||||||
'[datetime]' => $now->format('Ymd_His'),
|
'[DATETIME]' => $now->format('Ymd_His'),
|
||||||
'[year]' => $now->format('Y'),
|
'[YEAR]' => $now->format('Y'),
|
||||||
'[month]' => $now->format('m'),
|
'[MONTH]' => $now->format('m'),
|
||||||
'[day]' => $now->format('d'),
|
'[DAY]' => $now->format('d'),
|
||||||
'[hour]' => $now->format('H'),
|
'[HOUR]' => $now->format('H'),
|
||||||
'[minute]' => $now->format('i'),
|
'[MINUTE]' => $now->format('i'),
|
||||||
'[second]' => $now->format('s'),
|
'[SECOND]' => $now->format('s'),
|
||||||
'[profile_id]' => (string) ($profile->id ?? '0'),
|
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
|
||||||
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
|
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
|
||||||
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
|
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
|
||||||
'[type]' => $profile->backup_type ?? 'full',
|
'[TYPE]' => $profile->backup_type ?? 'full',
|
||||||
'[random]' => bin2hex(random_bytes(3)),
|
'[RANDOM]' => bin2hex(random_bytes(3)),
|
||||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||||
];
|
];
|
||||||
@@ -103,7 +128,7 @@ class PlaceholderResolver
|
|||||||
*/
|
*/
|
||||||
public function getHostname(): string
|
public function getHostname(): string
|
||||||
{
|
{
|
||||||
return $this->replacements['[host]'];
|
return $this->replacements['[HOST]'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +136,7 @@ class PlaceholderResolver
|
|||||||
*/
|
*/
|
||||||
public function getTag(): string
|
public function getTag(): string
|
||||||
{
|
{
|
||||||
return $this->replacements['[datetime]'];
|
return $this->replacements['[DATETIME]'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -278,6 +278,21 @@ class PreflightCheck
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'sftp':
|
||||||
|
if (empty($profile->sftp_host)) {
|
||||||
|
$this->warnings[] = 'SFTP host is not configured — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($profile->sftp_username)) {
|
||||||
|
$this->warnings[] = 'SFTP username is not configured — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) {
|
||||||
|
$this->warnings[] = 'SFTP requires either a private key or password — remote upload will fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
case 'google_drive':
|
case 'google_drive':
|
||||||
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) {
|
||||||
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
$this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class RestoreEngine
|
class RestoreEngine
|
||||||
{
|
{
|
||||||
@@ -166,6 +167,9 @@ class RestoreEngine
|
|||||||
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterRestore(true, $recordId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||||
@@ -185,6 +189,9 @@ class RestoreEngine
|
|||||||
$this->recursiveDelete($this->stagingDir);
|
$this->recursiveDelete($this->stagingDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterRestore(false, $recordId);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||||
@@ -285,6 +292,26 @@ class RestoreEngine
|
|||||||
@rmdir($dir);
|
@rmdir($dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterRestore(bool $success, int $recordId): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterRestore', [
|
||||||
|
'success' => $success,
|
||||||
|
'record_id' => $recordId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the restore result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<?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 uploader using the system sftp/scp binary with SSH key authentication.
|
||||||
|
*
|
||||||
|
* The private key is stored in the database (profile column) and written
|
||||||
|
* to a temp file with 0600 permissions at upload time, then deleted.
|
||||||
|
* This avoids leaving key files on the filesystem permanently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
class SftpUploader implements RemoteUploaderInterface
|
||||||
|
{
|
||||||
|
private string $host;
|
||||||
|
private int $port;
|
||||||
|
private string $username;
|
||||||
|
private string $keyData;
|
||||||
|
private string $passphrase;
|
||||||
|
private string $password;
|
||||||
|
private string $remotePath;
|
||||||
|
|
||||||
|
public function __construct(object $profile)
|
||||||
|
{
|
||||||
|
$this->host = $profile->sftp_host ?? '';
|
||||||
|
$this->port = (int) ($profile->sftp_port ?? 22);
|
||||||
|
$this->username = $profile->sftp_username ?? '';
|
||||||
|
$this->keyData = $profile->sftp_key_data ?? '';
|
||||||
|
$this->passphrase = $profile->sftp_passphrase ?? '';
|
||||||
|
$this->password = $profile->sftp_password ?? '';
|
||||||
|
$this->remotePath = rtrim($profile->sftp_path ?? '/backups', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(string $localPath, string $remoteName): array
|
||||||
|
{
|
||||||
|
if (empty($this->host)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->username)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP username is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->keyData) && empty($this->password)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP requires either a private key or password'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* Write key to temp file if using key auth */
|
||||||
|
if (!empty($this->keyData)) {
|
||||||
|
$keyFile = $this->writeTempKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure remote directory exists */
|
||||||
|
$this->ensureRemoteDir($keyFile);
|
||||||
|
|
||||||
|
/* Upload via scp */
|
||||||
|
$remoteTarget = $this->username . '@' . $this->host . ':' . $this->remotePath . '/' . $remoteName;
|
||||||
|
$cmd = $this->buildScpCommand($localPath, $remoteTarget, $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
$errorMsg = implode("\n", $output);
|
||||||
|
throw new \RuntimeException('scp failed (exit ' . $exitCode . '): ' . $errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify upload by checking remote file size */
|
||||||
|
$remoteFile = $this->remotePath . '/' . $remoteName;
|
||||||
|
$remoteSize = $this->getRemoteFileSize($remoteFile, $keyFile);
|
||||||
|
$localSize = filesize($localPath);
|
||||||
|
|
||||||
|
if ($remoteSize > 0 && $remoteSize !== $localSize) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Size mismatch after upload: local=' . $localSize . ' remote=' . $remoteSize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Uploaded via SFTP: ' . $remoteFile,
|
||||||
|
'remote_path' => $remoteFile,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP upload failed: ' . $e->getMessage()];
|
||||||
|
} finally {
|
||||||
|
$this->cleanupTempKey($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConnection(): array
|
||||||
|
{
|
||||||
|
if (empty($this->host)) {
|
||||||
|
return ['success' => false, 'message' => 'SFTP host is not configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!empty($this->keyData)) {
|
||||||
|
$keyFile = $this->writeTempKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = $this->buildSshCommand('echo "MokoSuiteBackup connection test OK" && hostname', $keyFile);
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
return ['success' => false, 'message' => 'SSH connection failed: ' . implode(' ', $output)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Connected to ' . $this->host . ' — ' . implode(' ', $output),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'message' => 'Connection test failed: ' . $e->getMessage()];
|
||||||
|
} finally {
|
||||||
|
$this->cleanupTempKey($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the private key from the database to a temp file with 0600 permissions.
|
||||||
|
*/
|
||||||
|
private function writeTempKey(): string
|
||||||
|
{
|
||||||
|
$tmpDir = sys_get_temp_dir();
|
||||||
|
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
||||||
|
|
||||||
|
/* Key is stored base64-encoded in the database — decode before writing */
|
||||||
|
$keyContent = base64_decode($this->keyData, true);
|
||||||
|
|
||||||
|
if ($keyContent === false) {
|
||||||
|
/* Fallback: might be raw PEM (legacy or paste) */
|
||||||
|
$keyContent = $this->keyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||||
|
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||||
|
}
|
||||||
|
|
||||||
|
chmod($keyFile, 0600);
|
||||||
|
|
||||||
|
return $keyFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the temp key file.
|
||||||
|
*/
|
||||||
|
private function cleanupTempKey(?string $keyFile): void
|
||||||
|
{
|
||||||
|
if ($keyFile !== null && is_file($keyFile)) {
|
||||||
|
unlink($keyFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the remote directory exists via ssh mkdir -p.
|
||||||
|
*/
|
||||||
|
private function ensureRemoteDir(?string $keyFile): void
|
||||||
|
{
|
||||||
|
$escapedPath = escapeshellarg($this->remotePath);
|
||||||
|
$cmd = $this->buildSshCommand('mkdir -p ' . $escapedPath, $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $exitCode);
|
||||||
|
|
||||||
|
/* mkdir -p exits 0 even if dir already exists, so only fail on non-zero */
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
throw new \RuntimeException('Cannot create remote directory: ' . implode(' ', $output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remote file size via ssh stat.
|
||||||
|
*/
|
||||||
|
private function getRemoteFileSize(string $remotePath, ?string $keyFile): int
|
||||||
|
{
|
||||||
|
$escapedPath = escapeshellarg($remotePath);
|
||||||
|
$cmd = $this->buildSshCommand('stat -c %s ' . $escapedPath . ' 2>/dev/null || echo -1', $keyFile);
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
exec($cmd . ' 2>&1', $output);
|
||||||
|
|
||||||
|
$size = (int) trim(implode('', $output));
|
||||||
|
|
||||||
|
return $size > 0 ? $size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an scp command string with proper SSH options.
|
||||||
|
*/
|
||||||
|
private function buildScpCommand(string $localPath, string $remoteTarget, ?string $keyFile): string
|
||||||
|
{
|
||||||
|
$parts = ['scp', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||||
|
|
||||||
|
if ($this->port !== 22) {
|
||||||
|
$parts[] = '-P';
|
||||||
|
$parts[] = (string) $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyFile !== null) {
|
||||||
|
$parts[] = '-i';
|
||||||
|
$parts[] = escapeshellarg($keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($this->passphrase)) {
|
||||||
|
/* scp doesn't natively support passphrase via CLI — requires ssh-agent or expect.
|
||||||
|
For now, key files should be unencrypted or use ssh-agent. */
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($localPath);
|
||||||
|
$parts[] = escapeshellarg($remoteTarget);
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an ssh command string for remote commands.
|
||||||
|
*/
|
||||||
|
private function buildSshCommand(string $remoteCmd, ?string $keyFile): string
|
||||||
|
{
|
||||||
|
$parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes'];
|
||||||
|
|
||||||
|
if ($this->port !== 22) {
|
||||||
|
$parts[] = '-p';
|
||||||
|
$parts[] = (string) $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($keyFile !== null) {
|
||||||
|
$parts[] = '-i';
|
||||||
|
$parts[] = escapeshellarg($keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts[] = escapeshellarg($this->username . '@' . $this->host);
|
||||||
|
$parts[] = escapeshellarg($remoteCmd);
|
||||||
|
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
|
|||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class SnapshotEngine
|
class SnapshotEngine
|
||||||
{
|
{
|
||||||
@@ -214,6 +215,9 @@ class SnapshotEngine
|
|||||||
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf(
|
'message' => sprintf(
|
||||||
@@ -227,6 +231,9 @@ class SnapshotEngine
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log('FATAL: ' . $e->getMessage());
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||||
@@ -327,6 +334,27 @@ class SnapshotEngine
|
|||||||
return $db->loadAssocList() ?: [];
|
return $db->loadAssocList() ?: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
||||||
|
'success' => $success,
|
||||||
|
'snapshot_id' => $snapshotId,
|
||||||
|
'content_types' => $contentTypes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the snapshot result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|||||||
defined('_JEXEC') or die;
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\Event\Event;
|
||||||
|
|
||||||
class SnapshotRestoreEngine
|
class SnapshotRestoreEngine
|
||||||
{
|
{
|
||||||
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
|
|||||||
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||||
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
|
|||||||
|
|
||||||
$this->log('FATAL: ' . $e->getMessage());
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||||
@@ -386,6 +393,208 @@ class SnapshotRestoreEngine
|
|||||||
return array_unique($tables);
|
return array_unique($tables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore only selected articles (and their related rows) from a snapshot.
|
||||||
|
*
|
||||||
|
* Uses merge/upsert mode: updates existing rows by ID, inserts missing ones.
|
||||||
|
*
|
||||||
|
* @param int $snapshotId Snapshot record ID
|
||||||
|
* @param array $articleIds Article IDs to restore
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message: string, restored?: int, log?: string}
|
||||||
|
*/
|
||||||
|
public function restoreSelectedArticles(int $snapshotId, array $articleIds): array
|
||||||
|
{
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
return ['success' => false, 'message' => 'No article IDs provided'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$articleIds = array_map('intval', $articleIds);
|
||||||
|
$articleIds = array_filter($articleIds, fn($id) => $id > 0);
|
||||||
|
|
||||||
|
if (empty($articleIds)) {
|
||||||
|
return ['success' => false, 'message' => 'No valid article IDs provided'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
|
// Load snapshot record
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $snapshotId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$record = $db->loadObject();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot not found: ' . $snapshotId];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== 'complete') {
|
||||||
|
return ['success' => false, 'message' => 'Cannot restore from failed snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot file not found: ' . $record->data_file];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('Loading snapshot file: ' . basename($record->data_file));
|
||||||
|
|
||||||
|
$json = file_get_contents($record->data_file);
|
||||||
|
|
||||||
|
if ($json === false) {
|
||||||
|
return ['success' => false, 'message' => 'Cannot read snapshot file'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot file contains invalid JSON: ' . json_last_error_msg()];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($data) || empty($data['tables'])) {
|
||||||
|
return ['success' => false, 'message' => 'Invalid snapshot data format: missing tables key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentTable = $data['tables']['#__content'] ?? [];
|
||||||
|
|
||||||
|
if (empty($contentTable)) {
|
||||||
|
return ['success' => false, 'message' => 'Snapshot does not contain articles'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter #__content rows to only selected article IDs
|
||||||
|
$selectedRows = array_filter($contentTable, fn($row) => in_array((int) ($row['id'] ?? 0), $articleIds, true));
|
||||||
|
|
||||||
|
if (empty($selectedRows)) {
|
||||||
|
return ['success' => false, 'message' => 'None of the selected article IDs exist in this snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$foundIds = array_map(fn($row) => (int) $row['id'], $selectedRows);
|
||||||
|
$this->log('Restoring ' . count($selectedRows) . ' articles: IDs ' . implode(', ', $foundIds));
|
||||||
|
|
||||||
|
// Filter workflow_associations for selected articles
|
||||||
|
$workflowRows = [];
|
||||||
|
|
||||||
|
if (!empty($data['tables']['#__workflow_associations'])) {
|
||||||
|
$workflowRows = array_filter(
|
||||||
|
$data['tables']['#__workflow_associations'],
|
||||||
|
fn($row) => in_array((int) ($row['item_id'] ?? 0), $foundIds, true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tag_map entries for selected articles
|
||||||
|
$tagMapRows = [];
|
||||||
|
|
||||||
|
if (!empty($data['tables']['#__contentitem_tag_map'])) {
|
||||||
|
$tagMapRows = array_filter(
|
||||||
|
$data['tables']['#__contentitem_tag_map'],
|
||||||
|
fn($row) => in_array((int) ($row['content_item_id'] ?? 0), $foundIds, true)
|
||||||
|
&& str_starts_with($row['type_alias'] ?? '', 'com_content.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $db->getPrefix();
|
||||||
|
$totalRows = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->transactionStart();
|
||||||
|
|
||||||
|
// Restore articles using merge/upsert
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__content');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__content', array_values($selectedRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__content: ' . $rowCount . ' rows restored');
|
||||||
|
|
||||||
|
// Restore workflow associations
|
||||||
|
if (!empty($workflowRows)) {
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__workflow_associations');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__workflow_associations', array_values($workflowRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__workflow_associations: ' . $rowCount . ' rows restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore tag map entries
|
||||||
|
if (!empty($tagMapRows)) {
|
||||||
|
$realTable = str_replace('#__', $prefix, '#__contentitem_tag_map');
|
||||||
|
$rowCount = $this->restoreMerge($db, $realTable, '#__contentitem_tag_map', array_values($tagMapRows));
|
||||||
|
$totalRows += $rowCount;
|
||||||
|
$this->log(' #__contentitem_tag_map: ' . $rowCount . ' rows restored');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->transactionCommit();
|
||||||
|
|
||||||
|
$this->log('Selective restore complete: ' . $totalRows . ' total rows');
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
try {
|
||||||
|
$profile = NotificationSender::getDefaultProfile();
|
||||||
|
|
||||||
|
if ($profile) {
|
||||||
|
$userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown';
|
||||||
|
$userIdVal = Factory::getApplication()->getIdentity()->id ?? 0;
|
||||||
|
|
||||||
|
NotificationSender::sendRestoreNotification($profile, 'snapshot_selective_restore', [
|
||||||
|
'mode' => 'selective',
|
||||||
|
'article_ids' => $foundIds,
|
||||||
|
'row_count' => $totalRows,
|
||||||
|
'user' => $userName . ' (ID: ' . $userIdVal . ')',
|
||||||
|
], implode("\n", $this->log));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||||
|
'restored' => count($selectedRows),
|
||||||
|
'log' => implode("\n", $this->log),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try {
|
||||||
|
$db->transactionRollback();
|
||||||
|
$this->log('Transaction rolled back');
|
||||||
|
} catch (\Exception $rollbackEx) {
|
||||||
|
$this->log('Rollback failed: ' . $rollbackEx->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log('FATAL: ' . $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch event for actionlog and other listeners
|
||||||
|
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||||
|
'log' => implode("\n", $this->log),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
|
||||||
|
*/
|
||||||
|
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$app = Factory::getApplication();
|
||||||
|
|
||||||
|
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
|
||||||
|
'success' => $success,
|
||||||
|
'snapshot_id' => $snapshotId,
|
||||||
|
'mode' => $mode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Never let a listener failure break the restore result, but log it
|
||||||
|
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ class SteppedBackupEngine
|
|||||||
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||||
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
$session->remoteKeepLocal = (bool) ($profile->remote_keep_local ?? true);
|
||||||
|
|
||||||
|
// Load multi-remote destinations from the remotes table
|
||||||
|
$session->remoteDestinations = $this->loadRemoteDestinations($db, $profileId);
|
||||||
|
$session->remoteIndex = 0;
|
||||||
|
|
||||||
// Resolve placeholders in directory and filename
|
// Resolve placeholders in directory and filename
|
||||||
$resolver = new PlaceholderResolver($profile);
|
$resolver = new PlaceholderResolver($profile);
|
||||||
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
$backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir));
|
||||||
@@ -81,9 +85,21 @@ class SteppedBackupEngine
|
|||||||
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir];
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = date('Y-m-d H:i:s');
|
$now = date('Y-m-d H:i:s');
|
||||||
$tag = $resolver->getTag();
|
$tag = $resolver->getTag();
|
||||||
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
|
$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';
|
$archiveName = $resolver->resolve($nameFormat) . '.zip';
|
||||||
|
|
||||||
$session->archivePath = $backupDir . '/' . $archiveName;
|
$session->archivePath = $backupDir . '/' . $archiveName;
|
||||||
@@ -135,13 +151,22 @@ class SteppedBackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$totalSteps += 1; // finalize step
|
$totalSteps += 1; // finalize step
|
||||||
$totalSteps += ($session->remoteStorage !== 'none') ? 1 : 0; // upload step
|
|
||||||
|
// Determine upload step count: one step per remote destination,
|
||||||
|
// or one step for legacy single-remote, or zero if no remotes.
|
||||||
|
$remoteCount = count($session->remoteDestinations);
|
||||||
|
|
||||||
|
if ($remoteCount > 0) {
|
||||||
|
$totalSteps += $remoteCount;
|
||||||
|
} elseif ($session->remoteStorage !== 'none') {
|
||||||
|
$totalSteps += 1;
|
||||||
|
}
|
||||||
|
|
||||||
$session->totalSteps = $totalSteps;
|
$session->totalSteps = $totalSteps;
|
||||||
$session->currentStep = 1;
|
$session->currentStep = 1;
|
||||||
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
$session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files';
|
||||||
$session->log('Backup initialized: ' . $session->description);
|
$session->log('Backup initialized: ' . $session->description);
|
||||||
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')');
|
$session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ', remotes: ' . $remoteCount . ')');
|
||||||
// Log any preflight warnings into the session
|
// Log any preflight warnings into the session
|
||||||
foreach ($preflightResult['warnings'] as $warning) {
|
foreach ($preflightResult['warnings'] as $warning) {
|
||||||
$session->log('PREFLIGHT WARNING: ' . $warning);
|
$session->log('PREFLIGHT WARNING: ' . $warning);
|
||||||
@@ -379,7 +404,17 @@ class SteppedBackupEngine
|
|||||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
$session->currentStep++;
|
$session->currentStep++;
|
||||||
$session->phase = ($session->remoteStorage !== 'none') ? 'upload' : 'complete';
|
|
||||||
|
// Determine next phase: multi-remote, legacy single-remote, or complete
|
||||||
|
$hasMultiRemote = !empty($session->remoteDestinations);
|
||||||
|
$hasLegacyRemote = $session->remoteStorage !== 'none';
|
||||||
|
|
||||||
|
if ($hasMultiRemote || $hasLegacyRemote) {
|
||||||
|
$session->phase = 'upload';
|
||||||
|
} else {
|
||||||
|
$session->phase = 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
$session->statusMessage = 'Archive finalized: ' . $sizeHuman;
|
||||||
$session->log('Archive finalized: ' . $sizeHuman);
|
$session->log('Archive finalized: ' . $sizeHuman);
|
||||||
|
|
||||||
@@ -390,6 +425,10 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload phase: send archive to remote storage.
|
* Upload phase: send archive to remote storage.
|
||||||
|
*
|
||||||
|
* When multi-remote destinations are configured, each call uploads to
|
||||||
|
* one destination (one step per remote). When only the legacy
|
||||||
|
* single-remote column is set, uploads in a single step.
|
||||||
*/
|
*/
|
||||||
private function stepUpload(SteppedSession $session): void
|
private function stepUpload(SteppedSession $session): void
|
||||||
{
|
{
|
||||||
@@ -397,61 +436,126 @@ class SteppedBackupEngine
|
|||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
$uploadFailed = false;
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Wrapped in its own try-catch so a remote failure does not mark
|
if (!empty($session->remoteDestinations)) {
|
||||||
// the entire backup as failed — the local archive is preserved.
|
// ── Multi-remote path ──────────────────────────────────
|
||||||
try {
|
$index = $session->remoteIndex;
|
||||||
// Reload profile for remote settings
|
|
||||||
$query = $db->getQuery(true)
|
|
||||||
->select('*')
|
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
|
||||||
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
|
||||||
$db->setQuery($query);
|
|
||||||
$profile = $db->loadObject();
|
|
||||||
|
|
||||||
$uploader = match ($session->remoteStorage) {
|
if ($index >= count($session->remoteDestinations)) {
|
||||||
'ftp' => new FtpUploader($profile),
|
// All remotes processed — move to complete
|
||||||
'google_drive' => new GoogleDriveUploader($profile),
|
$session->phase = 'complete';
|
||||||
's3' => new S3Uploader($profile),
|
$session->statusMessage = 'All remote uploads finished';
|
||||||
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
$this->completeRecord($session);
|
||||||
};
|
|
||||||
|
|
||||||
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
return;
|
||||||
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
}
|
||||||
|
|
||||||
if ($result['success']) {
|
$remote = (object) $session->remoteDestinations[$index];
|
||||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
|
||||||
$session->log('Remote upload complete: ' . $result['message']);
|
|
||||||
|
|
||||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
try {
|
||||||
@unlink($session->archivePath);
|
$title = $remote->title ?? ('Remote #' . ($index + 1));
|
||||||
$session->log('Local copy removed');
|
$type = $remote->type ?? 'unknown';
|
||||||
|
$params = json_decode($remote->params ?? '{}', true) ?: [];
|
||||||
|
|
||||||
|
$session->log('Uploading to: ' . $title . ' (' . $type . ')...');
|
||||||
|
$uploader = $this->createUploaderFromParams($type, $params);
|
||||||
|
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
|
$session->log(' Upload complete: ' . $result['message']);
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$session->log(' WARNING: Upload failed: ' . $result['message']);
|
||||||
}
|
}
|
||||||
} else {
|
} catch (\Throwable $e) {
|
||||||
$uploadFailed = true;
|
$uploadFailed = true;
|
||||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
$session->log(' WARNING: Upload exception: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$session->remoteIndex++;
|
||||||
|
$session->currentStep++;
|
||||||
|
|
||||||
|
$remaining = count($session->remoteDestinations) - $session->remoteIndex;
|
||||||
|
$session->statusMessage = 'Uploaded to ' . ($remote->title ?? 'remote') . ($remaining > 0 ? ' (' . $remaining . ' remaining)' : '');
|
||||||
|
|
||||||
|
if ($session->remoteIndex >= count($session->remoteDestinations)) {
|
||||||
|
// All remotes done — delete local if configured and no failures
|
||||||
|
if (!$uploadFailed && !$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
|
@unlink($session->archivePath);
|
||||||
|
$session->log('Local copy removed (remote_keep_local = off)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update record with remote filename
|
||||||
|
$update = (object) [
|
||||||
|
'id' => $session->recordId,
|
||||||
|
'remote_filename' => $remoteFilename,
|
||||||
|
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||||
|
];
|
||||||
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = $uploadFailed
|
||||||
|
? 'Backup complete (some remote uploads failed — local archive preserved)'
|
||||||
|
: 'Backup complete';
|
||||||
|
$this->completeRecord($session, $uploadFailed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Legacy single-remote fallback ──────────────────────
|
||||||
|
try {
|
||||||
|
// Reload profile for remote settings
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_profiles'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . $session->profileId);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$profile = $db->loadObject();
|
||||||
|
|
||||||
|
$uploader = match ($session->remoteStorage) {
|
||||||
|
'ftp' => new FtpUploader($profile),
|
||||||
|
'sftp' => new SftpUploader($profile),
|
||||||
|
'google_drive' => new GoogleDriveUploader($profile),
|
||||||
|
's3' => new S3Uploader($profile),
|
||||||
|
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
|
||||||
|
};
|
||||||
|
|
||||||
|
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
|
||||||
|
$result = $uploader->upload($session->archivePath, $session->archiveName);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
||||||
|
$session->log('Remote upload complete: ' . $result['message']);
|
||||||
|
|
||||||
|
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
||||||
|
@unlink($session->archivePath);
|
||||||
|
$session->log('Local copy removed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
||||||
|
$session->log('Local backup is preserved.');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$uploadFailed = true;
|
||||||
|
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||||
$session->log('Local backup is preserved.');
|
$session->log('Local backup is preserved.');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$uploadFailed = true;
|
// Update record with remote filename
|
||||||
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
$update = (object) [
|
||||||
$session->log('Local backup is preserved.');
|
'id' => $session->recordId,
|
||||||
|
'remote_filename' => $remoteFilename,
|
||||||
|
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
||||||
|
|
||||||
|
$session->currentStep++;
|
||||||
|
$session->phase = 'complete';
|
||||||
|
$session->statusMessage = $uploadFailed
|
||||||
|
? 'Backup complete (remote upload failed — local archive preserved)'
|
||||||
|
: 'Backup complete';
|
||||||
|
$this->completeRecord($session, $uploadFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record with remote filename
|
|
||||||
$update = (object) [
|
|
||||||
'id' => $session->recordId,
|
|
||||||
'remote_filename' => $remoteFilename,
|
|
||||||
'filesexist' => is_file($session->archivePath) ? 1 : 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
|
||||||
|
|
||||||
$session->currentStep++;
|
|
||||||
$session->phase = 'complete';
|
|
||||||
$session->statusMessage = $uploadFailed
|
|
||||||
? 'Backup complete (remote upload failed — local archive preserved)'
|
|
||||||
: 'Backup complete';
|
|
||||||
$this->completeRecord($session, $uploadFailed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -716,4 +820,58 @@ class SteppedBackupEngine
|
|||||||
return $tables;
|
return $tables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load enabled remote destinations for a profile from the remotes table.
|
||||||
|
*
|
||||||
|
* Returns an empty array when the table does not exist (pre-migration)
|
||||||
|
* so the caller can fall back to the legacy single-remote column.
|
||||||
|
*
|
||||||
|
* @param object $db Database driver
|
||||||
|
* @param int $profileId Profile ID
|
||||||
|
*
|
||||||
|
* @return array Array of remote destination rows (as associative arrays for JSON serialization)
|
||||||
|
*/
|
||||||
|
private function loadRemoteDestinations(object $db, int $profileId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
// Use loadAssocList so the data survives JSON serialization in SteppedSession
|
||||||
|
return $db->loadAssocList() ?: [];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Table does not exist yet (pre-migration) — fall back to legacy
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a remote uploader from JSON params (multi-remote destinations).
|
||||||
|
*
|
||||||
|
* Builds a fake profile-like object from the params array so the existing
|
||||||
|
* uploader constructors work without modification.
|
||||||
|
*
|
||||||
|
* @param string $type Remote type: ftp, sftp, s3, google_drive
|
||||||
|
* @param array $params Key-value params decoded from the remote's JSON
|
||||||
|
*
|
||||||
|
* @return RemoteUploaderInterface
|
||||||
|
*/
|
||||||
|
private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface
|
||||||
|
{
|
||||||
|
$fake = (object) $params;
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'ftp' => new FtpUploader($fake),
|
||||||
|
'sftp' => new SftpUploader($fake),
|
||||||
|
'google_drive' => new GoogleDriveUploader($fake),
|
||||||
|
's3' => new S3Uploader($fake),
|
||||||
|
default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class SteppedSession
|
|||||||
public bool $remoteKeepLocal = true;
|
public bool $remoteKeepLocal = true;
|
||||||
public string $encryptionPassword = '';
|
public string $encryptionPassword = '';
|
||||||
|
|
||||||
|
// Multi-remote destinations (loaded from #__mokosuitebackup_remotes)
|
||||||
|
public array $remoteDestinations = [];
|
||||||
|
public int $remoteIndex = 0;
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
public int $totalSteps = 0;
|
public int $totalSteps = 0;
|
||||||
public int $currentStep = 0;
|
public int $currentStep = 0;
|
||||||
|
|||||||
@@ -38,7 +38,30 @@ class FolderPickerField extends FormField
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build placeholder map for JS resolution
|
// Build placeholder map for JS resolution
|
||||||
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
/* Resolve hostname: prefer HTTP_HOST, then Joomla live_site config, then system hostname */
|
||||||
|
$rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '';
|
||||||
|
|
||||||
|
if (empty($rawHost) || $rawHost === 'localhost') {
|
||||||
|
try {
|
||||||
|
$liveSite = Factory::getApplication()->get('live_site', '');
|
||||||
|
|
||||||
|
if (!empty($liveSite)) {
|
||||||
|
$parsed = parse_url($liveSite, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (!empty($parsed)) {
|
||||||
|
$rawHost = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
/* fallback */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rawHost)) {
|
||||||
|
$rawHost = php_uname('n');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost);
|
||||||
$siteName = '';
|
$siteName = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -52,15 +75,15 @@ class FolderPickerField extends FormField
|
|||||||
$placeholders = [
|
$placeholders = [
|
||||||
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
||||||
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
||||||
'[host]' => $hostname,
|
'[HOST]' => $hostname,
|
||||||
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
'[SITE_NAME]' => $sanitizedSiteName ?: 'joomla',
|
||||||
'[profile_id]' => '1',
|
'[PROFILE_ID]' => '1',
|
||||||
'[profile_name]' => 'default',
|
'[PROFILE_NAME]' => 'default',
|
||||||
'[type]' => 'full',
|
'[TYPE]' => 'full',
|
||||||
'[year]' => date('Y'),
|
'[YEAR]' => date('Y'),
|
||||||
'[month]' => date('m'),
|
'[MONTH]' => date('m'),
|
||||||
'[day]' => date('d'),
|
'[DAY]' => date('d'),
|
||||||
'[date]' => date('Ymd'),
|
'[DATE]' => date('Ymd'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$placeholdersJson = json_encode($placeholders);
|
$placeholdersJson = json_encode($placeholders);
|
||||||
@@ -96,51 +119,140 @@ class FolderPickerField extends FormField
|
|||||||
<span class="icon-folder-open" aria-hidden="true"></span>
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
|
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
|
||||||
<span class="icon-question-circle" aria-hidden="true"></span>
|
<span class="icon-question-circle" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||||
|
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOST]" title="Server hostname">[HOST]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[SITE_NAME]" title="Joomla site name">[SITE_NAME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DATE]" title="Date (Ymd)">[DATE]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_ID]" title="Profile ID">[PROFILE_ID]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[PROFILE_NAME]" title="Profile name">[PROFILE_NAME]</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[TYPE]" title="Backup type">[TYPE]</button>
|
||||||
|
</div>
|
||||||
<div class="mt-1" id="{$id}_status">
|
<div class="mt-1" id="{$id}_status">
|
||||||
<small class="{$statusClass}">
|
<small class="{$statusClass}">
|
||||||
<span class="{$statusIcon}" aria-hidden="true"></span>
|
<span class="{$statusIcon}" aria-hidden="true"></span>
|
||||||
{$statusDetail}
|
{$statusDetail}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
|
||||||
|
</div>
|
||||||
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
||||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||||
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
|
|
||||||
|
<h6 class="text-primary">How Path Resolution Works</h6>
|
||||||
|
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Absolute Paths</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><code>/home/user/backups</code> — Fixed path on the server</li>
|
||||||
|
<li><code>/var/backups/joomla</code> — System backup directory</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Relative Paths</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
|
||||||
|
<table class="table table-sm mb-2">
|
||||||
|
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||||
|
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
|
||||||
|
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
|
||||||
|
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
|
||||||
|
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
|
||||||
|
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
|
||||||
|
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-primary mt-3">Available Placeholders</h6>
|
||||||
<table class="table table-sm table-striped">
|
<table class="table table-sm table-striped">
|
||||||
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
||||||
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
||||||
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
|
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
|
||||||
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
|
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
|
||||||
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
|
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</td><td><code>{$placeholders['[DATE]']}</code></td></tr>
|
||||||
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
|
<tr><td><code>[YEAR]</code></td><td>Four-digit year.</td><td><code>{$placeholders['[YEAR]']}</code></td></tr>
|
||||||
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
|
<tr><td><code>[MONTH]</code></td><td>Two-digit month (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
|
||||||
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
|
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
|
||||||
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
|
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
|
||||||
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
|
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
|
||||||
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
|
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</td><td><code>full</code></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h6>Recommended Paths</h6>
|
|
||||||
<ul class="list-unstyled">
|
<h6 class="text-primary mt-3">Recommended Configurations</h6>
|
||||||
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li>
|
<table class="table table-sm">
|
||||||
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li>
|
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
|
||||||
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li>
|
<tbody>
|
||||||
</ul>
|
<tr>
|
||||||
|
<td><strong>Single site, secure</strong></td>
|
||||||
|
<td><code>[HOME]/backups</code></td>
|
||||||
|
<td>Outside web root. Best for most sites.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Multiple sites on one server</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[HOST]</code></td>
|
||||||
|
<td>Each site gets its own subdirectory.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Date-organized</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
|
||||||
|
<td>Backups sorted by year and month.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Per-profile</strong></td>
|
||||||
|
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
|
||||||
|
<td>Separate directory for each backup profile.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Shared hosting (default)</strong></td>
|
||||||
|
<td><code>[DEFAULT_DIR]</code></td>
|
||||||
|
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="alert alert-info py-2 mt-3 mb-0">
|
||||||
|
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
@@ -155,6 +267,56 @@ class FolderPickerField extends FormField
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
/* Clickable placeholder insertion at cursor position */
|
||||||
|
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = document.getElementById(this.getAttribute('data-field'));
|
||||||
|
var ph = this.getAttribute('data-ph');
|
||||||
|
if (!target) return;
|
||||||
|
var start = target.selectionStart || 0;
|
||||||
|
var end = target.selectionEnd || 0;
|
||||||
|
var val = target.value;
|
||||||
|
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||||
|
/* Move cursor to after the inserted placeholder */
|
||||||
|
var newPos = start + ph.length;
|
||||||
|
target.setSelectionRange(newPos, newPos);
|
||||||
|
target.focus();
|
||||||
|
/* Trigger input event so status updates */
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Help button — open modal with Bootstrap 5 or fallback */
|
||||||
|
var helpBtn = document.getElementById('{$id}_helpBtn');
|
||||||
|
var helpModal = document.getElementById('{$id}_helpModal');
|
||||||
|
if (helpBtn && helpModal) {
|
||||||
|
helpBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
|
||||||
|
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
|
||||||
|
modal.show();
|
||||||
|
} else {
|
||||||
|
helpModal.classList.add('show');
|
||||||
|
helpModal.style.display = 'block';
|
||||||
|
helpModal.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
var backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'modal-backdrop fade show';
|
||||||
|
backdrop.id = '{$id}_backdrop';
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
|
||||||
|
helpModal.classList.remove('show');
|
||||||
|
helpModal.style.display = 'none';
|
||||||
|
helpModal.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
var bd = document.getElementById('{$id}_backdrop');
|
||||||
|
if (bd) bd.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var fieldId = '{$id}';
|
var fieldId = '{$id}';
|
||||||
var btn = document.getElementById(fieldId + '_btn');
|
var btn = document.getElementById(fieldId + '_btn');
|
||||||
var browser = document.getElementById(fieldId + '_browser');
|
var browser = document.getElementById(fieldId + '_browser');
|
||||||
@@ -162,7 +324,7 @@ class FolderPickerField extends FormField
|
|||||||
var input = document.getElementById(fieldId);
|
var input = document.getElementById(fieldId);
|
||||||
var placeholders = {$placeholdersJson};
|
var placeholders = {$placeholdersJson};
|
||||||
|
|
||||||
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
// Resolve placeholders in a path (forward: [SITE_NAME] -> actual value)
|
||||||
function resolve(path) {
|
function resolve(path) {
|
||||||
for (var key in placeholders) {
|
for (var key in placeholders) {
|
||||||
path = path.split(key).join(placeholders[key]);
|
path = path.split(key).join(placeholders[key]);
|
||||||
@@ -253,8 +415,54 @@ class FolderPickerField extends FormField
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show which placeholders are in use and their resolved values */
|
||||||
|
var resolvedDiv = document.getElementById(fieldId + '_resolved');
|
||||||
|
|
||||||
|
function updateResolvedDisplay() {
|
||||||
|
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
|
||||||
|
var val = input.value || '';
|
||||||
|
var found = false;
|
||||||
|
|
||||||
|
for (var key in placeholders) {
|
||||||
|
if (val.indexOf(key) !== -1 && placeholders[key]) {
|
||||||
|
found = true;
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge bg-light text-dark border me-1 mb-1';
|
||||||
|
badge.style.fontSize = '0.75rem';
|
||||||
|
badge.style.fontFamily = 'monospace';
|
||||||
|
|
||||||
|
var keySpan = document.createElement('strong');
|
||||||
|
keySpan.textContent = key;
|
||||||
|
badge.appendChild(keySpan);
|
||||||
|
|
||||||
|
badge.appendChild(document.createTextNode(' = '));
|
||||||
|
|
||||||
|
var valSpan = document.createElement('span');
|
||||||
|
valSpan.className = 'text-primary';
|
||||||
|
valSpan.textContent = placeholders[key];
|
||||||
|
badge.appendChild(valSpan);
|
||||||
|
|
||||||
|
resolvedDiv.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
var fullResolved = document.createElement('div');
|
||||||
|
fullResolved.className = 'mt-1';
|
||||||
|
var arrow = document.createElement('span');
|
||||||
|
arrow.className = 'text-muted';
|
||||||
|
arrow.textContent = 'EXAMPLE: ';
|
||||||
|
fullResolved.appendChild(arrow);
|
||||||
|
var code = document.createElement('code');
|
||||||
|
code.textContent = resolve(val);
|
||||||
|
fullResolved.appendChild(code);
|
||||||
|
resolvedDiv.appendChild(fullResolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
input.addEventListener('input', function() {
|
input.addEventListener('input', function() {
|
||||||
clearTimeout(checkTimer);
|
clearTimeout(checkTimer);
|
||||||
|
updateResolvedDisplay();
|
||||||
checkTimer = setTimeout(checkDirPermissions, 400);
|
checkTimer = setTimeout(checkDirPermissions, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,6 +576,7 @@ class FolderPickerField extends FormField
|
|||||||
|
|
||||||
// Run initial check on page load
|
// Run initial check on page load
|
||||||
setDefaultDirWarning();
|
setDefaultDirWarning();
|
||||||
|
updateResolvedDisplay();
|
||||||
checkDirPermissions();
|
checkDirPermissions();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?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
|
||||||
|
*
|
||||||
|
* Text field with clickable placeholder pills that insert at cursor position.
|
||||||
|
* Used for backup directory and archive name format fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
|
||||||
|
class PlaceholderTextField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'PlaceholderText';
|
||||||
|
|
||||||
|
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');
|
||||||
|
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
$max = (int) ($this->element['maxlength'] ?? 512);
|
||||||
|
|
||||||
|
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
|
||||||
|
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
|
||||||
|
|
||||||
|
if (empty($placeholders)) {
|
||||||
|
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
|
||||||
|
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
|
||||||
|
. ' class="form-control" maxlength="' . $max . '"'
|
||||||
|
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
|
||||||
|
|
||||||
|
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
|
||||||
|
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
|
||||||
|
|
||||||
|
foreach ($placeholders as $ph) {
|
||||||
|
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
|
||||||
|
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
|
||||||
|
. htmlspecialchars($ph) . '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
$html .= <<<JS
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var target = document.getElementById(this.getAttribute('data-field'));
|
||||||
|
var ph = this.getAttribute('data-ph');
|
||||||
|
if (!target) return;
|
||||||
|
var start = target.selectionStart || 0;
|
||||||
|
var end = target.selectionEnd || 0;
|
||||||
|
var val = target.value;
|
||||||
|
target.value = val.substring(0, start) + ph + val.substring(end);
|
||||||
|
var newPos = start + ph.length;
|
||||||
|
target.setSelectionRange(newPos, newPos);
|
||||||
|
target.focus();
|
||||||
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
JS;
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?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
|
||||||
|
*
|
||||||
|
* Custom field for SSH private key input.
|
||||||
|
* Supports both file upload (via FileReader JS) and paste-in textarea.
|
||||||
|
* The key content is stored in the database, not as a file on disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Form\FormField;
|
||||||
|
use Joomla\CMS\Language\Text;
|
||||||
|
|
||||||
|
class SshKeyField extends FormField
|
||||||
|
{
|
||||||
|
protected $type = 'SshKey';
|
||||||
|
|
||||||
|
protected function getInput(): string
|
||||||
|
{
|
||||||
|
$value = $this->value ?? '';
|
||||||
|
$id = $this->id;
|
||||||
|
$name = $this->name;
|
||||||
|
|
||||||
|
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
|
||||||
|
|
||||||
|
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<span class="badge bg-success me-2">'
|
||||||
|
. '<span class="icon-lock" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
|
||||||
|
. '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File upload button */
|
||||||
|
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
|
||||||
|
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
|
||||||
|
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
|
||||||
|
$html .= '</label>';
|
||||||
|
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
|
||||||
|
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
|
||||||
|
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
|
||||||
|
|
||||||
|
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
|
||||||
|
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
|
||||||
|
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
|
||||||
|
. '<span class="icon-times" aria-hidden="true"></span> '
|
||||||
|
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
|
||||||
|
. '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden field — key data is NEVER rendered as visible text.
|
||||||
|
On existing keys, we submit a sentinel value to preserve the DB value
|
||||||
|
unless a new file is uploaded or clear is clicked. */
|
||||||
|
if ($hasKey) {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="__KEEP_EXISTING__">';
|
||||||
|
} else {
|
||||||
|
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||||
|
. ' value="">';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '</div>';
|
||||||
|
$html .= $this->getScript();
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getScript(): string
|
||||||
|
{
|
||||||
|
return <<<'JS'
|
||||||
|
<script>
|
||||||
|
function mokoSshKeyFileSelected(fieldId, input) {
|
||||||
|
if (!input.files || !input.files[0]) return;
|
||||||
|
var file = input.files[0];
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
/* Base64 encode the key before storing in the hidden field */
|
||||||
|
var content = e.target.result;
|
||||||
|
var encoded = btoa(content);
|
||||||
|
document.getElementById(fieldId).value = encoded;
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = file.name + ' uploaded';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mokoSshKeyClear(fieldId) {
|
||||||
|
document.getElementById(fieldId).value = '';
|
||||||
|
var status = document.getElementById(fieldId + '-status');
|
||||||
|
if (status) status.textContent = 'Key removed';
|
||||||
|
var fileInput = document.getElementById(fieldId + '-file');
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,90 @@ class DashboardModel extends BaseDatabaseModel
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest snapshot info for the dashboard widget.
|
||||||
|
*/
|
||||||
|
public function getLatestSnapshot(): ?object
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('status') . ' = ' . $db->quote('complete'))
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
$db->setQuery($query, 0, 1);
|
||||||
|
|
||||||
|
return $db->loadObject() ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshot count.
|
||||||
|
*/
|
||||||
|
public function getSnapshotCount(): int
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return (int) $db->loadResult();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get backup size trend data for the last 30 days.
|
||||||
|
* Returns array of {date, total_size, count, status} grouped by day.
|
||||||
|
*/
|
||||||
|
public function getBackupTrend(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$cutoff = date('Y-m-d', strtotime('-30 days'));
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('DATE(' . $db->quoteName('backupstart') . ') AS backup_date')
|
||||||
|
->select('SUM(' . $db->quoteName('total_size') . ') AS day_size')
|
||||||
|
->select('COUNT(*) AS day_count')
|
||||||
|
->select('SUM(CASE WHEN ' . $db->quoteName('status') . ' = ' . $db->quote('fail') . ' THEN 1 ELSE 0 END) AS fail_count')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||||
|
->where('DATE(' . $db->quoteName('backupstart') . ') >= ' . $db->quote($cutoff))
|
||||||
|
->group('DATE(' . $db->quoteName('backupstart') . ')')
|
||||||
|
->order('backup_date ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage breakdown by profile.
|
||||||
|
*/
|
||||||
|
public function getStorageByProfile(): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('p.title AS profile_title')
|
||||||
|
->select('COUNT(*) AS backup_count')
|
||||||
|
->select('COALESCE(SUM(r.total_size), 0) AS total_size')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = r.profile_id')
|
||||||
|
->where($db->quoteName('r.status') . ' = ' . $db->quote('complete'))
|
||||||
|
->group($db->quoteName('r.profile_id'))
|
||||||
|
->order('total_size DESC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get published backup profiles for the quick-action selector.
|
* Get published backup profiles for the quick-action selector.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
|
|||||||
$query->select('a.*')
|
$query->select('a.*')
|
||||||
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
||||||
|
|
||||||
|
// Subquery: count of backup records per profile
|
||||||
|
$subQuery = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||||
|
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
|
||||||
|
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
|
||||||
|
|
||||||
$published = $this->getState('filter.published');
|
$published = $this->getState('filter.published');
|
||||||
|
|
||||||
if (is_numeric($published)) {
|
if (is_numeric($published)) {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?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\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Factory;
|
||||||
|
use Joomla\CMS\MVC\Model\AdminModel;
|
||||||
|
|
||||||
|
class RemoteModel extends AdminModel
|
||||||
|
{
|
||||||
|
public function getForm($data = [], $loadData = true)
|
||||||
|
{
|
||||||
|
$form = $this->loadForm(
|
||||||
|
'com_mokosuitebackup.remote',
|
||||||
|
'remote',
|
||||||
|
['control' => 'jform', 'load_data' => $loadData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $form ?: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadFormData(): object
|
||||||
|
{
|
||||||
|
$data = Factory::getApplication()->getUserState('com_mokosuitebackup.edit.remote.data', []);
|
||||||
|
|
||||||
|
if (empty($data)) {
|
||||||
|
$data = $this->getItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($data) ? (object) $data : $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTable($name = 'Remote', $prefix = 'Administrator', $options = [])
|
||||||
|
{
|
||||||
|
return parent::getTable($name, $prefix, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all enabled remotes for a given profile.
|
||||||
|
*
|
||||||
|
* @param int $profileId The profile ID
|
||||||
|
*
|
||||||
|
* @return array Array of remote objects
|
||||||
|
*/
|
||||||
|
public function getEnabledByProfile(int $profileId): array
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes'))
|
||||||
|
->where($db->quoteName('profile_id') . ' = ' . (int) $profileId)
|
||||||
|
->where($db->quoteName('enabled') . ' = 1')
|
||||||
|
->order($db->quoteName('ordering') . ' ASC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
|
||||||
|
return $db->loadObjectList() ?: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?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\Model;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\MVC\Model\ListModel;
|
||||||
|
use Joomla\Database\QueryInterface;
|
||||||
|
|
||||||
|
class RemotesModel extends ListModel
|
||||||
|
{
|
||||||
|
public function __construct($config = [])
|
||||||
|
{
|
||||||
|
if (empty($config['filter_fields'])) {
|
||||||
|
$config['filter_fields'] = [
|
||||||
|
'id', 'a.id',
|
||||||
|
'profile_id', 'a.profile_id',
|
||||||
|
'title', 'a.title',
|
||||||
|
'type', 'a.type',
|
||||||
|
'enabled', 'a.enabled',
|
||||||
|
'ordering', 'a.ordering',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getListQuery(): QueryInterface
|
||||||
|
{
|
||||||
|
$db = $this->getDatabase();
|
||||||
|
$query = $db->getQuery(true);
|
||||||
|
|
||||||
|
$query->select('a.*')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_remotes', 'a'));
|
||||||
|
|
||||||
|
// Join profile title
|
||||||
|
$query->select($db->quoteName('p.title', 'profile_title'))
|
||||||
|
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON p.id = a.profile_id');
|
||||||
|
|
||||||
|
// Filter by profile
|
||||||
|
$profileId = $this->getState('filter.profile_id');
|
||||||
|
|
||||||
|
if (is_numeric($profileId)) {
|
||||||
|
$query->where($db->quoteName('a.profile_id') . ' = ' . (int) $profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type
|
||||||
|
$type = $this->getState('filter.type');
|
||||||
|
|
||||||
|
if (!empty($type)) {
|
||||||
|
$query->where($db->quoteName('a.type') . ' = ' . $db->quote($type));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by enabled
|
||||||
|
$enabled = $this->getState('filter.enabled');
|
||||||
|
|
||||||
|
if (is_numeric($enabled)) {
|
||||||
|
$query->where($db->quoteName('a.enabled') . ' = ' . (int) $enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
$search = $this->getState('filter.search');
|
||||||
|
|
||||||
|
if (!empty($search)) {
|
||||||
|
$search = $db->quote('%' . $db->escape(trim($search), true) . '%');
|
||||||
|
$query->where('(' . $db->quoteName('a.title') . ' LIKE ' . $search . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderCol = $this->state->get('list.ordering', 'a.ordering');
|
||||||
|
$orderDir = $this->state->get('list.direction', 'ASC');
|
||||||
|
$query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir));
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function populateState($ordering = 'a.ordering', $direction = 'ASC'): void
|
||||||
|
{
|
||||||
|
parent::populateState($ordering, $direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,23 @@ class ProfileTable extends Table
|
|||||||
|
|
||||||
public function store($updateNulls = true): bool
|
public function store($updateNulls = true): bool
|
||||||
{
|
{
|
||||||
|
/* Handle SSH key sentinel — when __KEEP_EXISTING__ is submitted,
|
||||||
|
preserve the current DB value instead of overwriting with the sentinel.
|
||||||
|
This prevents the key from being exposed in the form HTML. */
|
||||||
|
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
|
||||||
|
if ($this->id) {
|
||||||
|
$db = $this->getDbo();
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select($db->quoteName('sftp_key_data'))
|
||||||
|
->from($db->quoteName($this->_tbl))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $this->id);
|
||||||
|
$db->setQuery($query);
|
||||||
|
$this->sftp_key_data = $db->loadResult() ?: '';
|
||||||
|
} else {
|
||||||
|
$this->sftp_key_data = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = parent::store($updateNulls);
|
$result = parent::store($updateNulls);
|
||||||
|
|
||||||
if ($result && !empty($this->backup_dir)) {
|
if ($result && !empty($this->backup_dir)) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?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\Table;
|
||||||
|
|
||||||
|
defined('_JEXEC') or die;
|
||||||
|
|
||||||
|
use Joomla\CMS\Table\Table;
|
||||||
|
use Joomla\Database\DatabaseDriver;
|
||||||
|
|
||||||
|
class RemoteTable extends Table
|
||||||
|
{
|
||||||
|
public function __construct(DatabaseDriver $db)
|
||||||
|
{
|
||||||
|
parent::__construct('#__mokosuitebackup_remotes', 'id', $db);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function check(): bool
|
||||||
|
{
|
||||||
|
if (empty($this->profile_id)) {
|
||||||
|
$this->setError('Profile ID is required.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validTypes = ['sftp', 's3', 'google_drive', 'ftp'];
|
||||||
|
|
||||||
|
if (empty($this->type) || !\in_array($this->type, $validTypes, true)) {
|
||||||
|
$this->setError('Invalid remote type. Must be one of: ' . implode(', ', $validTypes));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->title)) {
|
||||||
|
$this->title = ucfirst(str_replace('_', ' ', $this->type)) . ' Remote';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure params is valid JSON
|
||||||
|
if (!empty($this->params) && \is_string($this->params)) {
|
||||||
|
$decoded = json_decode($this->params);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
$this->setError('Remote params must be valid JSON.');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
if (empty($this->created) || $this->created === '0000-00-00 00:00:00') {
|
||||||
|
$this->created = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->modified = $now;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the params as a decoded object.
|
||||||
|
*
|
||||||
|
* @return object
|
||||||
|
*/
|
||||||
|
public function getParams(): object
|
||||||
|
{
|
||||||
|
if (empty($this->params)) {
|
||||||
|
return (object) [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($this->params);
|
||||||
|
|
||||||
|
return \is_object($decoded) ? $decoded : (object) [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set params from an array or object, encoding to JSON.
|
||||||
|
*
|
||||||
|
* @param array|object $params The parameters to encode
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setParams(array|object $params): void
|
||||||
|
{
|
||||||
|
$this->params = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -272,6 +272,6 @@ HTACCESS;
|
|||||||
*/
|
*/
|
||||||
public static function logPathFromArchive(string $archivePath): string
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,12 +120,22 @@ class HtmlView extends BaseHtmlView
|
|||||||
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
ToolbarHelper::custom('backups.restore', 'upload', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_RESTORE', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
if ($user->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.verify', 'shield', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_VERIFY', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.compare', 'copy', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_COMPARE', true);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
if ($user->authorise('core.delete', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
ToolbarHelper::deleteList('JGLOBAL_CONFIRM_DELETE', 'backups.delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($user->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) {
|
||||||
|
ToolbarHelper::custom('backups.purgeModal', 'trash', '', 'COM_MOKOJOOMBACKUP_TOOLBAR_PURGE', false);
|
||||||
|
}
|
||||||
|
|
||||||
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
if ($user->authorise('core.admin', 'com_mokosuitebackup')) {
|
||||||
ToolbarHelper::preferences('com_mokosuitebackup');
|
ToolbarHelper::preferences('com_mokosuitebackup');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,26 @@ class HtmlView extends BaseHtmlView
|
|||||||
public array $systemHealth = [];
|
public array $systemHealth = [];
|
||||||
public array $profiles = [];
|
public array $profiles = [];
|
||||||
public bool $defaultDirWarning = false;
|
public bool $defaultDirWarning = false;
|
||||||
|
public ?object $latestSnapshot = null;
|
||||||
|
public int $snapshotCount = 0;
|
||||||
|
public array $backupTrend = [];
|
||||||
|
public array $storageByProfile = [];
|
||||||
|
|
||||||
public function display($tpl = null): void
|
public function display($tpl = null): void
|
||||||
{
|
{
|
||||||
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
|
/** @var \Joomla\Component\MokoSuiteBackup\Administrator\Model\DashboardModel $model */
|
||||||
$model = $this->getModel();
|
$model = $this->getModel();
|
||||||
|
|
||||||
$this->lastBackup = $model->getLastBackup();
|
$this->lastBackup = $model->getLastBackup();
|
||||||
$this->nextScheduled = $model->getNextScheduled();
|
$this->nextScheduled = $model->getNextScheduled();
|
||||||
$this->stats = $model->getStats();
|
$this->stats = $model->getStats();
|
||||||
$this->systemHealth = $model->getSystemHealth();
|
$this->systemHealth = $model->getSystemHealth();
|
||||||
$this->profiles = $model->getProfiles();
|
$this->profiles = $model->getProfiles();
|
||||||
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
|
$this->defaultDirWarning = $model->isUsingDefaultBackupDir();
|
||||||
|
$this->latestSnapshot = $model->getLatestSnapshot();
|
||||||
|
$this->snapshotCount = $model->getSnapshotCount();
|
||||||
|
$this->backupTrend = $model->getBackupTrend();
|
||||||
|
$this->storageByProfile = $model->getStorageByProfile();
|
||||||
|
|
||||||
$this->addToolbar();
|
$this->addToolbar();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\Factory;
|
use Joomla\CMS\Factory;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||||
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
use Joomla\CMS\Toolbar\Toolbar;
|
||||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||||
|
|
||||||
class HtmlView extends BaseHtmlView
|
class HtmlView extends BaseHtmlView
|
||||||
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
|
|||||||
ToolbarHelper::save('profile.save');
|
ToolbarHelper::save('profile.save');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$isNew) {
|
||||||
|
$toolbar = Toolbar::getInstance();
|
||||||
|
$profileId = (int) $this->item->id;
|
||||||
|
|
||||||
|
// "Run Backup Now" button — links to backup start with CSRF token
|
||||||
|
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||||
|
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
|
||||||
|
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
|
||||||
|
->url($runUrl)
|
||||||
|
->icon('icon-play')
|
||||||
|
->buttonClass('btn btn-success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "View Backups" link button
|
||||||
|
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
|
||||||
|
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
|
||||||
|
->url($backupsUrl)
|
||||||
|
->icon('icon-database')
|
||||||
|
->buttonClass('btn btn-info');
|
||||||
|
}
|
||||||
|
|
||||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
|
||||||
|
<!-- Archive Browser -->
|
||||||
|
<h4 class="mt-4">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
|
||||||
|
</h4>
|
||||||
|
<div id="mb-detail-browse" class="bg-light rounded" style="max-height:400px; overflow-y:auto;">
|
||||||
|
<div id="mb-detail-browse-summary" class="p-2 text-muted" style="font-size:0.85rem;"></div>
|
||||||
|
<table class="table table-sm table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
|
||||||
|
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
|
||||||
|
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-detail-browse-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Backup Log -->
|
<!-- Backup Log -->
|
||||||
<h4 class="mt-4"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
|
<h4 class="mt-4"><?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?></h4>
|
||||||
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
|
<div id="mb-detail-log" class="bg-light p-3 rounded" style="max-height:400px; overflow-y:auto;">
|
||||||
@@ -104,22 +126,105 @@ $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false)
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var form = new URLSearchParams();
|
var AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
form.append('task', 'ajax.viewLog');
|
var TOKEN_NAME = <?php echo json_encode($ajaxToken); ?>;
|
||||||
form.append('id', <?php echo (int) $this->item->id; ?>);
|
|
||||||
form.append(<?php echo json_encode($ajaxToken); ?>, '1');
|
|
||||||
|
|
||||||
fetch(<?php echo json_encode($ajaxUrl); ?>, {
|
function postAjax(params) {
|
||||||
method: 'POST',
|
var form = new URLSearchParams();
|
||||||
body: form,
|
form.append(TOKEN_NAME, '1');
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
for (var k in params) {
|
||||||
})
|
if (params.hasOwnProperty(k)) {
|
||||||
.then(function(r) { return r.json(); })
|
form.append(k, params[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fetch(AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
}).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load log
|
||||||
|
postAjax({ task: 'ajax.viewLog', id: <?php echo (int) $this->item->id; ?> })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
|
document.getElementById('mb-detail-log-body').textContent = data.error ? data.message : data.log;
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
|
document.getElementById('mb-detail-log-body').textContent = 'Error: ' + err.message;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
<?php if ($this->item->status === 'complete' && !empty($this->item->filesexist)) : ?>
|
||||||
|
// Load archive contents
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseSetMessage(tbody, message, cssClass) {
|
||||||
|
tbody.textContent = '';
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', '3');
|
||||||
|
td.className = cssClass || 'text-center';
|
||||||
|
td.textContent = message;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseAddFileRow(tbody, file) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdName = document.createElement('td');
|
||||||
|
tdName.style.wordBreak = 'break-all';
|
||||||
|
tdName.style.fontSize = '0.85rem';
|
||||||
|
var code = document.createElement('code');
|
||||||
|
code.textContent = file.name;
|
||||||
|
tdName.appendChild(code);
|
||||||
|
tr.appendChild(tdName);
|
||||||
|
|
||||||
|
var tdSize = document.createElement('td');
|
||||||
|
tdSize.className = 'text-end text-nowrap';
|
||||||
|
tdSize.textContent = formatFileSize(file.size);
|
||||||
|
tr.appendChild(tdSize);
|
||||||
|
|
||||||
|
var tdComp = document.createElement('td');
|
||||||
|
tdComp.className = 'text-end text-nowrap';
|
||||||
|
tdComp.textContent = formatFileSize(file.compressed_size);
|
||||||
|
tr.appendChild(tdComp);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
var browseTbody = document.getElementById('mb-detail-browse-tbody');
|
||||||
|
var browseSummary = document.getElementById('mb-detail-browse-summary');
|
||||||
|
browseSetMessage(browseTbody, 'Loading...');
|
||||||
|
|
||||||
|
postAjax({ task: 'ajax.browseArchive', id: <?php echo (int) $this->item->id; ?> })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
browseSetMessage(browseTbody, data.message || 'Error', 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
browseTbody.textContent = '';
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
browseSetMessage(browseTbody, 'Archive is empty', 'text-center text-muted');
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < data.files.length; i++) {
|
||||||
|
browseAddFileRow(browseTbody, data.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
|
||||||
|
if (data.truncated) {
|
||||||
|
text += ' (showing first ' + data.files.length + ')';
|
||||||
|
}
|
||||||
|
browseSummary.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
browseSetMessage(browseTbody, 'Error: ' + err.message, 'text-danger');
|
||||||
|
});
|
||||||
|
<?php endif; ?>
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</span>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($item->status === 'complete' && $item->filesexist) : ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-info mb-browse-archive"
|
||||||
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>">
|
||||||
|
<span class="icon-folder-open"></span>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
<button type="button" class="btn btn-sm btn-outline-secondary mb-view-log"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_VIEW_LOG'); ?>">
|
||||||
@@ -184,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<div id="mokosuitebackup-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 id="mokosuitebackup-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);">
|
<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="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
<h3 id="mb-modal-title" style="margin:0 0 1rem;">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>Do not navigate away or close this window</strong> while the backup is running.
|
||||||
|
</div>
|
||||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||||
<div id="mb-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 id="mb-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>
|
</div>
|
||||||
@@ -485,6 +496,93 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
document.getElementById('mb-log-modal').style.display = 'none';
|
document.getElementById('mb-log-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Browse Archive modal handler
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseSetMessage(tbody, message, cssClass) {
|
||||||
|
tbody.textContent = '';
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
var td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', '3');
|
||||||
|
td.className = cssClass || 'text-center';
|
||||||
|
td.textContent = message;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseAddFileRow(tbody, file) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdName = document.createElement('td');
|
||||||
|
tdName.style.wordBreak = 'break-all';
|
||||||
|
tdName.style.fontSize = '0.85rem';
|
||||||
|
var code = document.createElement('code');
|
||||||
|
code.textContent = file.name;
|
||||||
|
tdName.appendChild(code);
|
||||||
|
tr.appendChild(tdName);
|
||||||
|
|
||||||
|
var tdSize = document.createElement('td');
|
||||||
|
tdSize.className = 'text-end text-nowrap';
|
||||||
|
tdSize.textContent = formatFileSize(file.size);
|
||||||
|
tr.appendChild(tdSize);
|
||||||
|
|
||||||
|
var tdComp = document.createElement('td');
|
||||||
|
tdComp.className = 'text-end text-nowrap';
|
||||||
|
tdComp.textContent = formatFileSize(file.compressed_size);
|
||||||
|
tr.appendChild(tdComp);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.mb-browse-archive');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var recordId = btn.getAttribute('data-id');
|
||||||
|
var modal = document.getElementById('mb-browse-modal');
|
||||||
|
var tbody = document.getElementById('mb-browse-tbody');
|
||||||
|
var summary = document.getElementById('mb-browse-summary');
|
||||||
|
browseSetMessage(tbody, 'Loading...');
|
||||||
|
summary.textContent = '';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
postAjax({ task: 'ajax.browseArchive', id: recordId })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) {
|
||||||
|
browseSetMessage(tbody, data.message || 'Error', 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.textContent = '';
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
browseSetMessage(tbody, 'Archive is empty', 'text-center text-muted');
|
||||||
|
} else {
|
||||||
|
for (var i = 0; i < data.files.length; i++) {
|
||||||
|
browseAddFileRow(tbody, data.files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = data.total_files + ' files, ' + formatFileSize(data.total_size) + ' uncompressed';
|
||||||
|
if (data.truncated) {
|
||||||
|
text += ' (showing first ' + data.files.length + ')';
|
||||||
|
}
|
||||||
|
summary.textContent = text;
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
browseSetMessage(tbody, 'Error: ' + err.message, 'text-danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.id === 'mb-browse-modal' || e.target.classList.contains('mb-browse-close')) {
|
||||||
|
document.getElementById('mb-browse-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -567,3 +665,351 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive Browser Modal -->
|
||||||
|
<div id="mb-browse-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:80vh;">
|
||||||
|
<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-folder-open" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_ARCHIVE'); ?>
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="btn-close mb-browse-close" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.75rem 1.5rem; border-bottom:1px solid #dee2e6; background:#f8f9fa;">
|
||||||
|
<small id="mb-browse-summary" class="text-muted"></small>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0; overflow-y:auto; flex:1;">
|
||||||
|
<table class="table table-sm table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_NAME'); ?></th>
|
||||||
|
<th class="text-end" style="width:100px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_SIZE'); ?></th>
|
||||||
|
<th class="text-end" style="width:120px;"><?php echo Text::_('COM_MOKOJOOMBACKUP_BROWSE_COL_COMPRESSED'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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;">
|
||||||
|
<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-copy" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TITLE'); ?>
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="btn-close mb-compare-close" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||||
|
<div id="mb-compare-loading" style="text-align:center; padding:2rem;">
|
||||||
|
<span class="icon-spinner icon-spin" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_LOADING'); ?>
|
||||||
|
</div>
|
||||||
|
<div id="mb-compare-error" style="display:none;" class="alert alert-danger"></div>
|
||||||
|
<table id="mb-compare-table" class="table table-striped" style="display:none;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FIELD'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 1</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_BACKUP'); ?> 2</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DELTA'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-compare-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var COMPARE_AJAX_URL = <?php echo json_encode($ajaxUrl); ?>;
|
||||||
|
var COMPARE_TOKEN = <?php echo json_encode($ajaxToken); ?>;
|
||||||
|
|
||||||
|
function mbCmpFormatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(1024));
|
||||||
|
if (i >= units.length) i = units.length - 1;
|
||||||
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbCmpFormatDuration(seconds) {
|
||||||
|
if (seconds <= 0) return '0s';
|
||||||
|
var m = Math.floor(seconds / 60);
|
||||||
|
var s = seconds % 60;
|
||||||
|
return m > 0 ? m + 'm ' + s + 's' : s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbCmpDeltaCell(value, unit) {
|
||||||
|
if (value === 0) return '<span class="text-muted">—</span>';
|
||||||
|
var isPositive = value > 0;
|
||||||
|
var colorClass = isPositive ? 'text-danger' : 'text-success';
|
||||||
|
var display;
|
||||||
|
if (unit === 'bytes') {
|
||||||
|
display = (isPositive ? '+' : '') + mbCmpFormatBytes(value);
|
||||||
|
} else if (unit === 'duration') {
|
||||||
|
display = (isPositive ? '+' : '-') + mbCmpFormatDuration(Math.abs(value));
|
||||||
|
} else {
|
||||||
|
display = (isPositive ? '+' : '') + value.toLocaleString();
|
||||||
|
}
|
||||||
|
return '<span class="fw-bold ' + colorClass + '">' + display + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mbShowCompareModal(id1, id2) {
|
||||||
|
var modal = document.getElementById('mb-compare-modal');
|
||||||
|
var loading = document.getElementById('mb-compare-loading');
|
||||||
|
var errorEl = document.getElementById('mb-compare-error');
|
||||||
|
var table = document.getElementById('mb-compare-table');
|
||||||
|
var body = document.getElementById('mb-compare-body');
|
||||||
|
|
||||||
|
modal.style.display = 'block';
|
||||||
|
loading.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
table.style.display = 'none';
|
||||||
|
body.innerHTML = '';
|
||||||
|
|
||||||
|
var form = new URLSearchParams();
|
||||||
|
form.append('task', 'ajax.compareBackups');
|
||||||
|
form.append('id1', id1);
|
||||||
|
form.append('id2', id2);
|
||||||
|
form.append(COMPARE_TOKEN, '1');
|
||||||
|
|
||||||
|
fetch(COMPARE_AJAX_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
errorEl.textContent = data.message || 'Error loading comparison';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var b1 = data.backup1;
|
||||||
|
var b2 = data.backup2;
|
||||||
|
var d = data.delta;
|
||||||
|
|
||||||
|
var dur1 = 0, dur2 = 0;
|
||||||
|
if (b1.backupstart !== '0000-00-00 00:00:00' && b1.backupend !== '0000-00-00 00:00:00') {
|
||||||
|
dur1 = (new Date(b1.backupend).getTime() - new Date(b1.backupstart).getTime()) / 1000;
|
||||||
|
}
|
||||||
|
if (b2.backupstart !== '0000-00-00 00:00:00' && b2.backupend !== '0000-00-00 00:00:00') {
|
||||||
|
dur2 = (new Date(b2.backupend).getTime() - new Date(b2.backupstart).getTime()) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = [
|
||||||
|
['<?php echo Text::_('JGRID_HEADING_ID', true); ?>', '#' + b1.id, '#' + b2.id, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION', true); ?>', b1.description || '—', b2.description || '—', ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_PROFILE', true); ?>', b1.profile_title || '—', b2.profile_title || '—', ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS', true); ?>', b1.status, b2.status, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE', true); ?>', b1.backup_type, b2.backup_type, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_SIZE', true); ?>', mbCmpFormatBytes(b1.total_size), mbCmpFormatBytes(b2.total_size), mbCmpDeltaCell(d.size_diff, 'bytes')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DB_SIZE', true); ?>', mbCmpFormatBytes(b1.db_size), mbCmpFormatBytes(b2.db_size), mbCmpDeltaCell(b2.db_size - b1.db_size, 'bytes')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_FILES_COUNT', true); ?>', b1.files_count.toLocaleString(), b2.files_count.toLocaleString(), mbCmpDeltaCell(d.files_diff, 'number')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_TABLES_COUNT', true); ?>', b1.tables_count.toLocaleString(), b2.tables_count.toLocaleString(), mbCmpDeltaCell(d.tables_diff, 'number')],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE', true); ?>', b1.backupstart, b2.backupstart, ''],
|
||||||
|
['<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_DURATION', true); ?>', mbCmpFormatDuration(dur1), mbCmpFormatDuration(dur2), mbCmpDeltaCell(d.duration_diff_seconds, 'duration')],
|
||||||
|
];
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
html += '<tr><td class="fw-bold">' + rows[i][0] + '</td><td>' + rows[i][1] + '</td><td>' + rows[i][2] + '</td><td>' + rows[i][3] + '</td></tr>';
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
table.style.display = 'table';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
errorEl.textContent = 'Error: ' + err.message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close compare modal
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.id === 'mb-compare-modal' || e.target.classList.contains('mb-compare-close')) {
|
||||||
|
document.getElementById('mb-compare-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept Compare toolbar button
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var compareBtn = document.querySelector('[onclick*="backups.compare"], .button-copy');
|
||||||
|
if (compareBtn) {
|
||||||
|
compareBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
var checked = document.querySelectorAll('input[name="cid[]"]:checked');
|
||||||
|
if (checked.length !== 2) {
|
||||||
|
alert('<?php echo Text::_('COM_MOKOJOOMBACKUP_COMPARE_SELECT_TWO', true); ?>');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mbShowCompareModal(checked[0].value, checked[1].value);
|
||||||
|
return false;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</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; ?>
|
||||||
|
|||||||
@@ -109,6 +109,122 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Row 1b: Snapshot Widget -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-camera" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_SNAPSHOTS'); ?>
|
||||||
|
</h5>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=snapshots'); ?>" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_VIEW_ALL'); ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if ($this->latestSnapshot) : ?>
|
||||||
|
<?php $types = json_decode($this->latestSnapshot->content_types, true) ?: []; ?>
|
||||||
|
<p class="mb-1">
|
||||||
|
<strong><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_LATEST_SNAPSHOT'); ?>:</strong>
|
||||||
|
<?php echo $this->escape($this->latestSnapshot->description); ?>
|
||||||
|
</p>
|
||||||
|
<p class="mb-1 text-muted">
|
||||||
|
<?php echo HTMLHelper::_('date', $this->latestSnapshot->created, Text::_('DATE_FORMAT_LC4')); ?>
|
||||||
|
—
|
||||||
|
<?php foreach ($types as $type) : ?>
|
||||||
|
<span class="badge bg-secondary"><?php echo $this->escape($type); ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<small class="text-muted">
|
||||||
|
<?php echo (int) $this->latestSnapshot->articles_count; ?> articles,
|
||||||
|
<?php echo (int) $this->latestSnapshot->categories_count; ?> categories,
|
||||||
|
<?php echo (int) $this->latestSnapshot->modules_count; ?> modules
|
||||||
|
— <?php echo $this->snapshotCount; ?> total snapshots
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<?php else : ?>
|
||||||
|
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_SNAPSHOTS'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Breakdown by Profile -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN'); ?>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($this->storageByProfile)) : ?>
|
||||||
|
<?php
|
||||||
|
$maxSize = max(array_column($this->storageByProfile, 'total_size')) ?: 1;
|
||||||
|
$colors = ['#0d6efd', '#198754', '#ffc107', '#dc3545', '#6f42c1', '#0dcaf0'];
|
||||||
|
?>
|
||||||
|
<?php foreach ($this->storageByProfile as $i => $profile) : ?>
|
||||||
|
<?php $pct = round(($profile->total_size / $maxSize) * 100); ?>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between small">
|
||||||
|
<span><?php echo $this->escape($profile->profile_title ?: 'Unknown'); ?> (<?php echo (int) $profile->backup_count; ?>)</span>
|
||||||
|
<span><?php echo HTMLHelper::_('number.bytes', $profile->total_size); ?></span>
|
||||||
|
</div>
|
||||||
|
<div style="background:#e9ecef; border-radius:3px; height:8px; overflow:hidden;">
|
||||||
|
<div style="width:<?php echo $pct; ?>%; height:100%; background:<?php echo $colors[$i % count($colors)]; ?>; border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else : ?>
|
||||||
|
<p class="text-muted mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_NO_BACKUPS'); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup Trend (30 days) -->
|
||||||
|
<?php if (!empty($this->backupTrend)) : ?>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<span class="icon-chart" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND'); ?>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php
|
||||||
|
$maxDaySize = max(array_column($this->backupTrend, 'day_size')) ?: 1;
|
||||||
|
?>
|
||||||
|
<div style="display:flex; align-items:flex-end; gap:2px; height:120px; overflow-x:auto;">
|
||||||
|
<?php foreach ($this->backupTrend as $day) : ?>
|
||||||
|
<?php
|
||||||
|
$barHeight = max(4, round(($day->day_size / $maxDaySize) * 100));
|
||||||
|
$barColor = $day->fail_count > 0 ? '#dc3545' : '#198754';
|
||||||
|
$tooltip = date('M j', strtotime($day->backup_date))
|
||||||
|
. ' — ' . $day->day_count . ' backup(s), '
|
||||||
|
. number_format($day->day_size / 1048576, 1) . ' MB'
|
||||||
|
. ($day->fail_count > 0 ? ', ' . $day->fail_count . ' failed' : '');
|
||||||
|
?>
|
||||||
|
<div style="flex:1; min-width:8px; max-width:24px; height:<?php echo $barHeight; ?>%; background:<?php echo $barColor; ?>; border-radius:2px 2px 0 0; cursor:default;"
|
||||||
|
title="<?php echo htmlspecialchars($tooltip); ?>"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
<small class="text-muted"><?php echo date('M j', strtotime('-30 days')); ?></small>
|
||||||
|
<small class="text-muted"><?php echo date('M j'); ?></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Row 2: Quick Actions -->
|
<!-- Row 2: Quick Actions -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -189,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
|||||||
<div id="mokosuitebackup-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 id="mokosuitebackup-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);">
|
<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="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
<h3 id="mb-modal-title" style="margin:0 0 1rem;">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>Do not navigate away or close this window</strong> while the backup is running.
|
||||||
|
</div>
|
||||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||||
<div id="mb-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 id="mb-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>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\HTML\HTMLHelper;
|
use Joomla\CMS\HTML\HTMLHelper;
|
||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
HTMLHelper::_('behavior.formvalidator');
|
HTMLHelper::_('behavior.formvalidator');
|
||||||
HTMLHelper::_('behavior.keepalive');
|
HTMLHelper::_('behavior.keepalive');
|
||||||
|
|
||||||
|
$profileId = (int) $this->item->id;
|
||||||
|
$token = Session::getFormToken();
|
||||||
?>
|
?>
|
||||||
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . (int) $this->item->id); ?>"
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&layout=edit&id=' . $profileId); ?>"
|
||||||
method="post" name="adminForm" id="adminForm" class="form-validate">
|
method="post" name="adminForm" id="adminForm" class="form-validate">
|
||||||
|
|
||||||
<div class="main-card">
|
<div class="main-card">
|
||||||
@@ -60,11 +64,53 @@ HTMLHelper::_('behavior.keepalive');
|
|||||||
|
|
||||||
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
<?php echo HTMLHelper::_('uitab.addTab', 'profileTab', 'remote', Text::_('COM_MOKOJOOMBACKUP_TAB_REMOTE')); ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-9">
|
<div class="col-lg-12">
|
||||||
<?php echo $this->form->renderFieldset('remote'); ?>
|
<?php // ---- Remote Destinations (multi-remote) ---- ?>
|
||||||
<?php echo $this->form->renderFieldset('ftp'); ?>
|
<?php if ($profileId): ?>
|
||||||
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
<div id="mokoRemoteDestinations" class="mb-4">
|
||||||
<?php echo $this->form->renderFieldset('s3'); ?>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 class="mb-0"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DESTINATIONS'); ?></h3>
|
||||||
|
<button type="button" class="btn btn-success btn-sm" id="btnAddRemote">
|
||||||
|
<span class="icon-plus" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table" id="remoteDestTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th style="width:120px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||||
|
<th style="width:100px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATUS'); ?></th>
|
||||||
|
<th style="width:160px"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="remoteDestBody">
|
||||||
|
<tr id="remoteDestLoading">
|
||||||
|
<td colspan="4" class="text-center text-muted">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-muted small" id="remoteDestEmpty" style="display:none;">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_NONE_CONFIGURED'); ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php // ---- Legacy single-remote fields ---- ?>
|
||||||
|
<div id="legacyRemoteFields">
|
||||||
|
<div class="alert alert-info small" id="legacyRemoteNote" style="display:none;">
|
||||||
|
<span class="icon-info-circle" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_LEGACY_NOTE'); ?>
|
||||||
|
</div>
|
||||||
|
<?php echo $this->form->renderFieldset('remote'); ?>
|
||||||
|
<?php echo $this->form->renderFieldset('ftp'); ?>
|
||||||
|
<?php echo $this->form->renderFieldset('google_drive'); ?>
|
||||||
|
<?php echo $this->form->renderFieldset('s3'); ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
<?php echo HTMLHelper::_('uitab.endTab'); ?>
|
||||||
@@ -75,3 +121,495 @@ HTMLHelper::_('behavior.keepalive');
|
|||||||
<input type="hidden" name="task" value="">
|
<input type="hidden" name="task" value="">
|
||||||
<?php echo HTMLHelper::_('form.token'); ?>
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php // ---- Remote Destination Add/Edit Modal ---- ?>
|
||||||
|
<?php if ($profileId): ?>
|
||||||
|
<div class="modal fade" id="remoteModal" tabindex="-1" aria-labelledby="remoteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="remoteModalLabel"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD'); ?></h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php echo Text::_('JCLOSE'); ?>"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="remoteEditId" value="0">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteTitle" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteTitle" maxlength="255" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="remoteType" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></label>
|
||||||
|
<select class="form-select" id="remoteType">
|
||||||
|
<option value="sftp"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP'); ?></option>
|
||||||
|
<option value="s3"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3'); ?></option>
|
||||||
|
<option value="google_drive"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE'); ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ENABLED'); ?></label>
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remoteEnabled" checked>
|
||||||
|
<label class="form-check-label" for="remoteEnabled"><?php echo Text::_('JYES'); ?></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_KEEP_LOCAL'); ?></label>
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="remoteKeepLocal" checked>
|
||||||
|
<label class="form-check-label" for="remoteKeepLocal"><?php echo Text::_('JYES'); ?></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<?php // SFTP fields ?>
|
||||||
|
<div id="remoteFields_sftp" class="remote-type-fields">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="remoteCfg_sftp_host" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_HOST'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_host" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="remoteCfg_sftp_port" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT'); ?></label>
|
||||||
|
<input type="number" class="form-control" id="remoteCfg_sftp_port" value="22" min="1" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_username" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_username" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_auth_type" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE'); ?></label>
|
||||||
|
<select class="form-select" id="remoteCfg_sftp_auth_type">
|
||||||
|
<option value="key"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY'); ?></option>
|
||||||
|
<option value="password"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD'); ?></option>
|
||||||
|
<option value="key_passphrase"><?php echo Text::_('COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE'); ?></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpPasswordWrap">
|
||||||
|
<label for="remoteCfg_sftp_password" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_sftp_password" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpKeyWrap">
|
||||||
|
<label for="remoteCfg_sftp_key_data" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY'); ?></label>
|
||||||
|
<textarea class="form-control" id="remoteCfg_sftp_key_data" rows="4" placeholder="Paste SSH private key or leave as-is to keep existing"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="remoteSftpPassphraseWrap">
|
||||||
|
<label for="remoteCfg_sftp_passphrase" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_sftp_passphrase" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_sftp_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_sftp_path" value="/backups" maxlength="512">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php // S3 fields ?>
|
||||||
|
<div id="remoteFields_s3" class="remote-type-fields" style="display:none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_endpoint" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ENDPOINT'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_endpoint" maxlength="512" placeholder="https://s3.amazonaws.com">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_region" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_REGION'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_region" value="us-east-1" maxlength="50">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_access_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_ACCESS_KEY'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_access_key" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_secret_key" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_SECRET_KEY'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_s3_secret_key" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_bucket" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_BUCKET'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_bucket" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_s3_path" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_S3_PATH'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_s3_path" value="/backups" maxlength="512">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php // Google Drive fields ?>
|
||||||
|
<div id="remoteFields_google_drive" class="remote-type-fields" style="display:none;">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_client_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_ID'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_client_id" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_client_secret" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_CLIENT_SECRET'); ?></label>
|
||||||
|
<input type="password" class="form-control" id="remoteCfg_gdrive_client_secret" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_refresh_token" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_REFRESH_TOKEN'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_refresh_token" maxlength="512">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="remoteCfg_gdrive_folder_id" class="form-label"><?php echo Text::_('COM_MOKOJOOMBACKUP_FIELD_GDRIVE_FOLDER_ID'); ?></label>
|
||||||
|
<input type="text" class="form-control" id="remoteCfg_gdrive_folder_id" maxlength="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
|
<button type="button" class="btn btn-primary" id="btnSaveRemote">
|
||||||
|
<span class="icon-save" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('JAPPLY'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const profileId = <?php echo $profileId; ?>;
|
||||||
|
const token = '<?php echo $token; ?>';
|
||||||
|
|
||||||
|
if (!profileId) return;
|
||||||
|
|
||||||
|
const baseUrl = 'index.php?option=com_mokosuitebackup&task=ajax.';
|
||||||
|
const tbody = document.getElementById('remoteDestBody');
|
||||||
|
const emptyMsg = document.getElementById('remoteDestEmpty');
|
||||||
|
const loadingTr = document.getElementById('remoteDestLoading');
|
||||||
|
const legacy = document.getElementById('legacyRemoteFields');
|
||||||
|
const legacyNote = document.getElementById('legacyRemoteNote');
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('remoteModal'));
|
||||||
|
|
||||||
|
// Type badge colours
|
||||||
|
const typeBadge = {sftp: 'bg-primary', s3: 'bg-warning text-dark', google_drive: 'bg-success'};
|
||||||
|
const typeLabel = {
|
||||||
|
sftp: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_SFTP', true); ?>',
|
||||||
|
s3: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_S3', true); ?>',
|
||||||
|
google_drive: '<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_GDRIVE', true); ?>'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Config field mappings per type
|
||||||
|
const configFields = {
|
||||||
|
sftp: ['host','port','username','auth_type','password','key_data','passphrase','path'],
|
||||||
|
s3: ['endpoint','region','access_key','secret_key','bucket','path'],
|
||||||
|
google_drive: ['client_id','client_secret','refresh_token','folder_id']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefix mapping for config field IDs
|
||||||
|
const fieldPrefix = {sftp: 'sftp_', s3: 's3_', google_drive: 'gdrive_'};
|
||||||
|
|
||||||
|
let remotesData = [];
|
||||||
|
|
||||||
|
// ---- Load remotes ----
|
||||||
|
function loadRemotes() {
|
||||||
|
loadingTr.style.display = '';
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
|
||||||
|
fetch(baseUrl + 'listRemotes&profile_id=' + profileId + '&' + token + '=1', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
loadingTr.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
showTableMessage(data.message, 'text-danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remotesData = data.items || [];
|
||||||
|
renderTable();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loadingTr.style.display = 'none';
|
||||||
|
showTableMessage('Failed to load remotes', 'text-danger');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
|
||||||
|
if (!remotesData.length) {
|
||||||
|
emptyMsg.style.display = '';
|
||||||
|
legacy.style.display = '';
|
||||||
|
legacyNote.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyMsg.style.display = 'none';
|
||||||
|
legacy.style.display = 'none';
|
||||||
|
legacyNote.style.display = 'block';
|
||||||
|
|
||||||
|
remotesData.forEach(function(item) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// Title cell
|
||||||
|
const tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = item.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
// Type badge cell
|
||||||
|
const tdType = document.createElement('td');
|
||||||
|
const badgeSpan = document.createElement('span');
|
||||||
|
badgeSpan.className = 'badge ' + (typeBadge[item.type] || 'bg-secondary');
|
||||||
|
badgeSpan.textContent = typeLabel[item.type] || item.type;
|
||||||
|
tdType.appendChild(badgeSpan);
|
||||||
|
tr.appendChild(tdType);
|
||||||
|
|
||||||
|
// Enabled toggle cell
|
||||||
|
const tdEnabled = document.createElement('td');
|
||||||
|
const toggleSpan = document.createElement('span');
|
||||||
|
toggleSpan.className = 'badge ' + (item.enabled ? 'bg-success' : 'bg-secondary');
|
||||||
|
toggleSpan.style.cursor = 'pointer';
|
||||||
|
toggleSpan.setAttribute('data-toggle-id', item.id);
|
||||||
|
toggleSpan.textContent = item.enabled ? 'Enabled' : 'Disabled';
|
||||||
|
tdEnabled.appendChild(toggleSpan);
|
||||||
|
tr.appendChild(tdEnabled);
|
||||||
|
|
||||||
|
// Actions cell
|
||||||
|
const tdActions = document.createElement('td');
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'btn btn-sm btn-outline-primary me-1';
|
||||||
|
editBtn.setAttribute('data-edit-id', item.id);
|
||||||
|
editBtn.title = 'Edit';
|
||||||
|
const editIcon = document.createElement('span');
|
||||||
|
editIcon.className = 'icon-pencil';
|
||||||
|
editIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
editBtn.appendChild(editIcon);
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.type = 'button';
|
||||||
|
delBtn.className = 'btn btn-sm btn-outline-danger';
|
||||||
|
delBtn.setAttribute('data-delete-id', item.id);
|
||||||
|
delBtn.title = 'Delete';
|
||||||
|
const delIcon = document.createElement('span');
|
||||||
|
delIcon.className = 'icon-trash';
|
||||||
|
delIcon.setAttribute('aria-hidden', 'true');
|
||||||
|
delBtn.appendChild(delIcon);
|
||||||
|
tdActions.appendChild(delBtn);
|
||||||
|
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTableMessage(message, cssClass) {
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.setAttribute('colspan', '4');
|
||||||
|
td.className = cssClass || '';
|
||||||
|
td.textContent = message;
|
||||||
|
tr.appendChild(td);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Toggle enabled ----
|
||||||
|
tbody.addEventListener('click', function(e) {
|
||||||
|
const toggle = e.target.closest('[data-toggle-id]');
|
||||||
|
if (toggle) {
|
||||||
|
const id = toggle.getAttribute('data-toggle-id');
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', id);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
|
||||||
|
fetch(baseUrl + 'toggleRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { if (!data.error) loadRemotes(); })
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBtn = e.target.closest('[data-edit-id]');
|
||||||
|
if (editBtn) {
|
||||||
|
openEdit(parseInt(editBtn.getAttribute('data-edit-id'), 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delBtn = e.target.closest('[data-delete-id]');
|
||||||
|
if (delBtn) {
|
||||||
|
if (!confirm('<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_DELETE_CONFIRM', true); ?>')) return;
|
||||||
|
const id = delBtn.getAttribute('data-delete-id');
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', id);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
|
||||||
|
fetch(baseUrl + 'deleteRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => { if (!data.error) loadRemotes(); })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Add button ----
|
||||||
|
document.getElementById('btnAddRemote').addEventListener('click', function() {
|
||||||
|
openEdit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Open modal for add / edit ----
|
||||||
|
function openEdit(id) {
|
||||||
|
document.getElementById('remoteEditId').value = id;
|
||||||
|
document.getElementById('remoteTitle').value = '';
|
||||||
|
document.getElementById('remoteType').value = 'sftp';
|
||||||
|
document.getElementById('remoteEnabled').checked = true;
|
||||||
|
document.getElementById('remoteKeepLocal').checked = true;
|
||||||
|
|
||||||
|
// Clear all config fields
|
||||||
|
document.querySelectorAll('.remote-type-fields input, .remote-type-fields textarea, .remote-type-fields select').forEach(function(el) {
|
||||||
|
if (el.type === 'number') {
|
||||||
|
el.value = el.defaultValue || '';
|
||||||
|
} else if (el.tagName === 'SELECT') {
|
||||||
|
el.selectedIndex = 0;
|
||||||
|
} else {
|
||||||
|
el.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore defaults
|
||||||
|
const portField = document.getElementById('remoteCfg_sftp_port');
|
||||||
|
if (portField) portField.value = '22';
|
||||||
|
const s3Region = document.getElementById('remoteCfg_s3_region');
|
||||||
|
if (s3Region) s3Region.value = 'us-east-1';
|
||||||
|
const sftpPath = document.getElementById('remoteCfg_sftp_path');
|
||||||
|
if (sftpPath) sftpPath.value = '/backups';
|
||||||
|
const s3Path = document.getElementById('remoteCfg_s3_path');
|
||||||
|
if (s3Path) s3Path.value = '/backups';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const item = remotesData.find(r => r.id === id);
|
||||||
|
if (item) {
|
||||||
|
document.getElementById('remoteTitle').value = item.title;
|
||||||
|
document.getElementById('remoteType').value = item.type;
|
||||||
|
document.getElementById('remoteEnabled').checked = !!item.enabled;
|
||||||
|
document.getElementById('remoteKeepLocal').checked = !!item.keep_local;
|
||||||
|
|
||||||
|
// Populate config fields
|
||||||
|
const prefix = fieldPrefix[item.type] || '';
|
||||||
|
const fields = configFields[item.type] || [];
|
||||||
|
fields.forEach(function(f) {
|
||||||
|
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||||
|
if (el && item.config && item.config[f] !== undefined) {
|
||||||
|
el.value = item.config[f];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('remoteModalLabel').textContent =
|
||||||
|
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_EDIT', true); ?>';
|
||||||
|
} else {
|
||||||
|
document.getElementById('remoteModalLabel').textContent =
|
||||||
|
'<?php echo Text::_('COM_MOKOJOOMBACKUP_REMOTE_ADD', true); ?>';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTypeFields();
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Type selector toggles field visibility ----
|
||||||
|
document.getElementById('remoteType').addEventListener('change', updateTypeFields);
|
||||||
|
|
||||||
|
function updateTypeFields() {
|
||||||
|
const type = document.getElementById('remoteType').value;
|
||||||
|
document.querySelectorAll('.remote-type-fields').forEach(function(el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const target = document.getElementById('remoteFields_' + type);
|
||||||
|
if (target) target.style.display = '';
|
||||||
|
|
||||||
|
// SFTP auth_type sub-fields
|
||||||
|
if (type === 'sftp') {
|
||||||
|
updateSftpAuthFields();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sftpAuthType = document.getElementById('remoteCfg_sftp_auth_type');
|
||||||
|
if (sftpAuthType) {
|
||||||
|
sftpAuthType.addEventListener('change', updateSftpAuthFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSftpAuthFields() {
|
||||||
|
const auth = document.getElementById('remoteCfg_sftp_auth_type').value;
|
||||||
|
document.getElementById('remoteSftpPasswordWrap').style.display = (auth === 'password') ? '' : 'none';
|
||||||
|
document.getElementById('remoteSftpKeyWrap').style.display = (auth === 'key' || auth === 'key_passphrase') ? '' : 'none';
|
||||||
|
document.getElementById('remoteSftpPassphraseWrap').style.display = (auth === 'key_passphrase') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save remote ----
|
||||||
|
document.getElementById('btnSaveRemote').addEventListener('click', function() {
|
||||||
|
const type = document.getElementById('remoteType').value;
|
||||||
|
const title = document.getElementById('remoteTitle').value.trim();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
document.getElementById('remoteTitle').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build config object from visible fields
|
||||||
|
const config = {};
|
||||||
|
const prefix = fieldPrefix[type] || '';
|
||||||
|
const fields = configFields[type] || [];
|
||||||
|
|
||||||
|
fields.forEach(function(f) {
|
||||||
|
const el = document.getElementById('remoteCfg_' + prefix + f);
|
||||||
|
if (el) {
|
||||||
|
config[f] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set(token, '1');
|
||||||
|
body.set('remote_id', document.getElementById('remoteEditId').value);
|
||||||
|
body.set('profile_id', profileId);
|
||||||
|
body.set('remote_title', title);
|
||||||
|
body.set('remote_type', type);
|
||||||
|
body.set('remote_enabled', document.getElementById('remoteEnabled').checked ? '1' : '0');
|
||||||
|
body.set('remote_keep_local', document.getElementById('remoteKeepLocal').checked ? '1' : '0');
|
||||||
|
body.set('remote_config', JSON.stringify(config));
|
||||||
|
|
||||||
|
document.getElementById('btnSaveRemote').disabled = true;
|
||||||
|
|
||||||
|
fetch(baseUrl + 'saveRemote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('btnSaveRemote').disabled = false;
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
alert(data.message || 'Save failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.hide();
|
||||||
|
loadRemotes();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('btnSaveRemote').disabled = false;
|
||||||
|
alert('Network error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadRemotes();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
|
|||||||
use Joomla\CMS\Language\Text;
|
use Joomla\CMS\Language\Text;
|
||||||
use Joomla\CMS\Layout\LayoutHelper;
|
use Joomla\CMS\Layout\LayoutHelper;
|
||||||
use Joomla\CMS\Router\Route;
|
use Joomla\CMS\Router\Route;
|
||||||
|
use Joomla\CMS\Session\Session;
|
||||||
|
|
||||||
HTMLHelper::_('behavior.multiselect');
|
HTMLHelper::_('behavior.multiselect');
|
||||||
|
|
||||||
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<th scope="col" class="w-10">
|
<th scope="col" class="w-10">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" class="w-5 text-center">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
|
||||||
|
</th>
|
||||||
<th scope="col" class="w-10">
|
<th scope="col" class="w-10">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" class="w-10 text-center">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||||
|
</th>
|
||||||
<th scope="col" class="w-5">
|
<th scope="col" class="w-5">
|
||||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||||
</th>
|
</th>
|
||||||
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
<td>
|
<td>
|
||||||
<?php echo $this->escape($item->backup_type); ?>
|
<?php echo $this->escape($item->backup_type); ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
|
||||||
|
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||||
|
<?php echo (int) $item->backup_count; ?>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<?php if ($item->published == 1) : ?>
|
||||||
|
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
||||||
|
class="btn btn-sm btn-outline-success"
|
||||||
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
|
||||||
|
<span class="icon-play" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php echo (int) $item->id; ?>
|
<?php echo (int) $item->id; ?>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||||
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
|
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||||
|
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||||
|
<span class="icon-search"></span>
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||||
data-id="<?php echo (int) $item->id; ?>"
|
data-id="<?php echo (int) $item->id; ?>"
|
||||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||||
@@ -227,6 +233,116 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Browse Snapshot Detail Modal -->
|
||||||
|
<div id="mb-snapshot-browse-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); max-height:80vh; display:flex; flex-direction:column;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||||
|
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
||||||
|
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form action="<?php echo Route::_('index.php?option=com_mokosuitebackup&task=snapshots.restoreSelected'); ?>" method="post" id="mb-snapshot-browse-form">
|
||||||
|
<input type="hidden" name="id" id="mb-browse-id" value="">
|
||||||
|
<div style="padding:1rem 1.5rem; overflow-y:auto; flex:1;">
|
||||||
|
<div id="mb-browse-loading" class="text-center py-4">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_LOADING'); ?>
|
||||||
|
</div>
|
||||||
|
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||||
|
<div id="mb-browse-content" style="display:none;">
|
||||||
|
|
||||||
|
<!-- Bootstrap tabs -->
|
||||||
|
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
|
||||||
|
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content pt-3" id="mb-browse-tabs-content">
|
||||||
|
|
||||||
|
<!-- Articles tab -->
|
||||||
|
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-check form-check-inline">
|
||||||
|
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||||
|
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||||
|
</label>
|
||||||
|
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-1"></th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories tab -->
|
||||||
|
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-categories-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-categories-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modules tab -->
|
||||||
|
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
|
||||||
|
<table class="table table-sm table-striped" id="mb-browse-modules-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
|
||||||
|
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mb-browse-modules-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
||||||
|
<button type="button" class="btn btn-secondary mb-modal-close"><?php echo Text::_('JCANCEL'); ?></button>
|
||||||
|
<button type="submit" class="btn btn-success" id="mb-browse-restore-btn" disabled>
|
||||||
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
|
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED'); ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php echo HTMLHelper::_('form.token'); ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
// Create Snapshot — intercept toolbar button
|
// Create Snapshot — intercept toolbar button
|
||||||
@@ -287,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
var label = document.createElement('label');
|
var label = document.createElement('label');
|
||||||
label.className = 'form-check-label';
|
label.className = 'form-check-label';
|
||||||
label.setAttribute('for', 'mb-rtype-' + type);
|
label.setAttribute('for', 'mb-rtype-' + type);
|
||||||
label.textContent = typeLabels[type] || type;
|
label.textContent = typeLabels[TYPE] || type;
|
||||||
|
|
||||||
div.appendChild(input);
|
div.appendChild(input);
|
||||||
div.appendChild(label);
|
div.appendChild(label);
|
||||||
@@ -312,13 +428,204 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
document.getElementById('mb-replace-warning').style.display = isReplace ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Browse Snapshot — click handler
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('.mb-snapshot-browse');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var desc = btn.getAttribute('data-desc');
|
||||||
|
|
||||||
|
document.getElementById('mb-browse-id').value = id;
|
||||||
|
document.getElementById('mb-browse-title').textContent = 'Browse: ' + desc;
|
||||||
|
|
||||||
|
// Reset modal state
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'block';
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-content').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||||
|
document.getElementById('mb-browse-select-all').checked = false;
|
||||||
|
|
||||||
|
// Reset to Articles tab
|
||||||
|
var firstTab = document.querySelector('#mb-tab-articles-btn');
|
||||||
|
if (firstTab && typeof bootstrap !== 'undefined') {
|
||||||
|
var tab = new bootstrap.Tab(firstTab);
|
||||||
|
tab.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||||
|
|
||||||
|
// Fetch snapshot content via AJAX
|
||||||
|
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||||
|
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||||
|
|
||||||
|
fetch(url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function(response) { return response.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById('mb-browse-error').textContent = data.message;
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||||
|
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||||
|
|
||||||
|
// --- Articles ---
|
||||||
|
var tbody = document.getElementById('mb-browse-tbody');
|
||||||
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||||
|
|
||||||
|
(data.articles || []).forEach(function(article) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdCheck = document.createElement('td');
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'form-check-input mb-browse-article-cb';
|
||||||
|
cb.name = 'article_ids[]';
|
||||||
|
cb.value = article.id;
|
||||||
|
tdCheck.appendChild(cb);
|
||||||
|
tr.appendChild(tdCheck);
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = article.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = article.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(article.state)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(article.state)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
var tdDate = document.createElement('td');
|
||||||
|
tdDate.textContent = article.created ? article.created.substring(0, 10) : '';
|
||||||
|
tr.appendChild(tdDate);
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
|
||||||
|
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
|
||||||
|
|
||||||
|
// --- Categories ---
|
||||||
|
var catTbody = document.getElementById('mb-browse-categories-tbody');
|
||||||
|
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
|
||||||
|
|
||||||
|
(data.categories || []).forEach(function(cat) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = cat.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdExt = document.createElement('td');
|
||||||
|
tdExt.textContent = cat.extension;
|
||||||
|
tr.appendChild(tdExt);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
var tdLevel = document.createElement('td');
|
||||||
|
tdLevel.textContent = cat.level;
|
||||||
|
tr.appendChild(tdLevel);
|
||||||
|
|
||||||
|
catTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
|
||||||
|
|
||||||
|
// --- Modules ---
|
||||||
|
var modTbody = document.getElementById('mb-browse-modules-tbody');
|
||||||
|
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
|
||||||
|
|
||||||
|
(data.modules || []).forEach(function(mod) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
var tdId = document.createElement('td');
|
||||||
|
tdId.textContent = mod.id;
|
||||||
|
tr.appendChild(tdId);
|
||||||
|
|
||||||
|
var tdTitle = document.createElement('td');
|
||||||
|
tdTitle.textContent = mod.title;
|
||||||
|
tr.appendChild(tdTitle);
|
||||||
|
|
||||||
|
var tdType = document.createElement('td');
|
||||||
|
tdType.textContent = mod.module;
|
||||||
|
tr.appendChild(tdType);
|
||||||
|
|
||||||
|
var tdPos = document.createElement('td');
|
||||||
|
tdPos.textContent = mod.position;
|
||||||
|
tr.appendChild(tdPos);
|
||||||
|
|
||||||
|
var tdState = document.createElement('td');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
|
||||||
|
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
|
||||||
|
tdState.appendChild(badge);
|
||||||
|
tr.appendChild(tdState);
|
||||||
|
|
||||||
|
modTbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
|
||||||
|
|
||||||
|
document.getElementById('mb-browse-content').style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||||
|
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
|
||||||
|
document.getElementById('mb-browse-error').style.display = 'block';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Browse — select all toggle
|
||||||
|
document.addEventListener('change', function(e) {
|
||||||
|
if (e.target.id === 'mb-browse-select-all') {
|
||||||
|
var checked = e.target.checked;
|
||||||
|
var checkboxes = document.querySelectorAll('.mb-browse-article-cb');
|
||||||
|
checkboxes.forEach(function(cb) { cb.checked = checked; });
|
||||||
|
updateBrowseRestoreBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('mb-browse-article-cb')) {
|
||||||
|
updateBrowseRestoreBtn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBrowseRestoreBtn() {
|
||||||
|
var checked = document.querySelectorAll('.mb-browse-article-cb:checked').length;
|
||||||
|
var btn = document.getElementById('mb-browse-restore-btn');
|
||||||
|
btn.disabled = checked === 0;
|
||||||
|
btn.textContent = checked > 0
|
||||||
|
? <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?> + ' (' + checked + ')'
|
||||||
|
: <?php echo json_encode(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED')); ?>;
|
||||||
|
}
|
||||||
|
|
||||||
// Close modals
|
// Close modals
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.classList.contains('mb-modal-close') ||
|
if (e.target.classList.contains('mb-modal-close') ||
|
||||||
e.target.id === 'mb-snapshot-create-modal' ||
|
e.target.id === 'mb-snapshot-create-modal' ||
|
||||||
e.target.id === 'mb-snapshot-restore-modal') {
|
e.target.id === 'mb-snapshot-restore-modal' ||
|
||||||
|
e.target.id === 'mb-snapshot-browse-modal') {
|
||||||
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
document.getElementById('mb-snapshot-create-modal').style.display = 'none';
|
||||||
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
document.getElementById('mb-snapshot-restore-modal').style.display = 'none';
|
||||||
|
document.getElementById('mb-snapshot-browse-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+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.41.03</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>
|
||||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
|||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||||
|
|||||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
|||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||||
|
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="actionlog" method="upgrade">
|
<extension type="plugin" group="actionlog" method="upgrade">
|
||||||
<name>Action Log - MokoSuiteBackup</name>
|
<name>Action Log - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
+92
-1
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
|||||||
return [
|
return [
|
||||||
'onContentAfterSave' => 'onContentAfterSave',
|
'onContentAfterSave' => 'onContentAfterSave',
|
||||||
'onContentAfterDelete' => 'onContentAfterDelete',
|
'onContentAfterDelete' => 'onContentAfterDelete',
|
||||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||||
|
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
|
||||||
|
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
|
||||||
|
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a backup is restored.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterRestore(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$recordId = $args['record_id'] ?? 0;
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $recordId,
|
||||||
|
'title' => 'Backup #' . $recordId,
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.backup',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a content snapshot is created.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||||
|
$contentTypes = $args['content_types'] ?? [];
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $snapshotId,
|
||||||
|
'title' => 'Snapshot #' . $snapshotId,
|
||||||
|
'content_types' => implode(', ', $contentTypes),
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.snapshot',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when a snapshot is restored.
|
||||||
|
*/
|
||||||
|
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
|
||||||
|
{
|
||||||
|
$args = $event->getArguments();
|
||||||
|
|
||||||
|
$success = $args['success'] ?? false;
|
||||||
|
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||||
|
$mode = $args['mode'] ?? 'replace';
|
||||||
|
|
||||||
|
$messageKey = $success
|
||||||
|
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
|
||||||
|
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
|
||||||
|
|
||||||
|
$this->addLog(
|
||||||
|
[
|
||||||
|
$messageKey,
|
||||||
|
'id' => $snapshotId,
|
||||||
|
'title' => 'Snapshot #' . $snapshotId,
|
||||||
|
'mode' => $mode,
|
||||||
|
'userid' => $this->getCurrentUserId(),
|
||||||
|
'username' => $this->getCurrentUserName(),
|
||||||
|
],
|
||||||
|
$messageKey,
|
||||||
|
'com_mokosuitebackup.snapshot',
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write an action log entry.
|
* Write an action log entry.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="console" method="upgrade">
|
<extension type="plugin" group="console" method="upgrade">
|
||||||
<name>Console - MokoSuiteBackup</name>
|
<name>Console - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine;
|
|||||||
use Joomla\Console\Command\AbstractCommand;
|
use Joomla\Console\Command\AbstractCommand;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ class RestoreCommand extends AbstractCommand
|
|||||||
{
|
{
|
||||||
$this->setDescription('Restore a backup by record ID');
|
$this->setDescription('Restore a backup by record ID');
|
||||||
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
|
$this->addArgument('id', InputArgument::REQUIRED, 'Backup record ID to restore');
|
||||||
|
$this->addOption('files-only', null, InputOption::VALUE_NONE, 'Restore files only (skip database)');
|
||||||
|
$this->addOption('db-only', null, InputOption::VALUE_NONE, 'Restore database only (skip files)');
|
||||||
|
$this->addOption('no-preserve-config', null, InputOption::VALUE_NONE, 'Do not preserve current configuration.php');
|
||||||
|
$this->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Decryption password for encrypted archives', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function doExecute(InputInterface $input, OutputInterface $output): int
|
protected function doExecute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -85,8 +90,22 @@ class RestoreCommand extends AbstractCommand
|
|||||||
require_once $engineFile;
|
require_once $engineFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$filesOnly = $input->getOption('files-only');
|
||||||
|
$dbOnly = $input->getOption('db-only');
|
||||||
|
$preserveConfig = !$input->getOption('no-preserve-config');
|
||||||
|
$password = $input->getOption('password') ?: '';
|
||||||
|
|
||||||
|
$restoreFiles = !$dbOnly;
|
||||||
|
$restoreDb = !$filesOnly;
|
||||||
|
|
||||||
|
if ($filesOnly) {
|
||||||
|
$io->note('Restoring files only (database will not be touched)');
|
||||||
|
} elseif ($dbOnly) {
|
||||||
|
$io->note('Restoring database only (files will not be touched)');
|
||||||
|
}
|
||||||
|
|
||||||
$engine = new RestoreEngine();
|
$engine = new RestoreEngine();
|
||||||
$result = $engine->restore($recordId);
|
$result = $engine->restore($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password);
|
||||||
|
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$io->success($result['message']);
|
$io->success($result['message']);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="content" method="upgrade">
|
<extension type="plugin" group="content" method="upgrade">
|
||||||
<name>Content - MokoSuiteBackup</name>
|
<name>Content - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<extension type="plugin" group="quickicon" method="upgrade">
|
<extension type="plugin" group="quickicon" method="upgrade">
|
||||||
<name>Quick Icon - MokoSuiteBackup</name>
|
<name>Quick Icon - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="system" method="upgrade">
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
<name>System - MokoSuiteBackup</name>
|
<name>System - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="task" method="upgrade">
|
<extension type="plugin" group="task" method="upgrade">
|
||||||
<name>Task - MokoSuiteBackup</name>
|
<name>Task - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="webservices" method="upgrade">
|
<extension type="plugin" group="webservices" method="upgrade">
|
||||||
<name>Web Services - MokoSuiteBackup</name>
|
<name>Web Services - MokoSuiteBackup</name>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteBackup</name>
|
<name>Package - MokoSuiteBackup</name>
|
||||||
<packagename>mokosuitebackup</packagename>
|
<packagename>mokosuitebackup</packagename>
|
||||||
<version>01.32.00</version>
|
<version>01.41.03</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<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="console">plg_console_mokosuitebackup.zip</file>
|
||||||
<file type="plugin" id="mokosuitebackup" group="content">plg_content_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="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>
|
</files>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|||||||
+24
-24
@@ -58,7 +58,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check required PHP extensions (warn but don't block install)
|
/* Check required PHP extensions (warn but don't block install) */
|
||||||
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
|
$requiredExts = ['zip', 'pdo', 'pdo_mysql', 'mbstring', 'curl'];
|
||||||
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
|
$missingExts = array_filter($requiredExts, fn($ext) => !extension_loaded($ext));
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save download key before Joomla re-registers the update site
|
/* Save download key before Joomla re-registers the update site */
|
||||||
if ($type === 'update') {
|
if ($type === 'update') {
|
||||||
$this->preflight_saveKey();
|
$this->preflight_saveKey();
|
||||||
}
|
}
|
||||||
@@ -138,43 +138,43 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore download key if it was saved before update
|
/* Restore download key if it was saved before update */
|
||||||
if ($this->savedDownloadKey !== null) {
|
if ($this->savedDownloadKey !== null) {
|
||||||
$this->restoreDownloadKey();
|
$this->restoreDownloadKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'install') {
|
if ($type === 'install') {
|
||||||
// Enable all bundled plugins on fresh install
|
/* Enable all bundled plugins on fresh install */
|
||||||
$this->enableBundledPlugins();
|
$this->enableBundledPlugins();
|
||||||
|
|
||||||
// Create default backup directory in site root
|
/* Create default backup directory in site root */
|
||||||
$this->createBackupDirectory();
|
$this->createBackupDirectory();
|
||||||
|
|
||||||
// Generate a random webcron secret word
|
/* Generate a random webcron secret word */
|
||||||
$this->generateWebcronSecret();
|
$this->generateWebcronSecret();
|
||||||
|
|
||||||
// Create default scheduled task for backup automation
|
/* Create default scheduled task for backup automation */
|
||||||
$this->createDefaultScheduledTask();
|
$this->createDefaultScheduledTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure submenu items exist and are up to date
|
/* Ensure submenu items exist and are up to date */
|
||||||
// (Joomla may not add new submenu entries or update params on upgrades)
|
/* (Joomla may not add new submenu entries or update params on upgrades) */
|
||||||
$this->ensureSubmenuItems();
|
$this->ensureSubmenuItems();
|
||||||
|
|
||||||
// Fix package client_id — packages must be client_id=0 (site) for
|
/* Fix package client_id — packages must be client_id=0 (site) for */
|
||||||
// Joomla's updater to match the <client>site</client> in updates.xml
|
/* Joomla's updater to match the <client>site</client> in updates.xml */
|
||||||
$this->fixPackageClientId();
|
$this->fixPackageClientId();
|
||||||
|
|
||||||
// Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades)
|
/* Sync submenu icons in #__menu (Joomla doesn't update icons on upgrades) */
|
||||||
$this->syncMenuIcons();
|
$this->syncMenuIcons();
|
||||||
|
|
||||||
// Warn if no license key configured
|
/* Warn if no license key configured */
|
||||||
$this->warnMissingLicenseKey();
|
$this->warnMissingLicenseKey();
|
||||||
|
|
||||||
// Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder
|
/* Migrate profiles with old default backup_dir values to [DEFAULT_DIR] placeholder */
|
||||||
$this->migrateDefaultBackupDir();
|
$this->migrateDefaultBackupDir();
|
||||||
|
|
||||||
// Remind user to review backup profile settings
|
/* Remind user to review backup profile settings */
|
||||||
if ($type === 'install') {
|
if ($type === 'install') {
|
||||||
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
|
$profileUrl = Route::_('index.php?option=com_mokosuitebackup&view=profiles');
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Load current component params
|
/* Load current component params */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('params'))
|
->select($db->quoteName('params'))
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
@@ -208,7 +208,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
|
|
||||||
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
$params = json_decode($rawParams ?: '{}', true) ?: [];
|
||||||
|
|
||||||
// Only generate if not already set
|
/* Only generate if not already set */
|
||||||
if (!empty($params['webcron_secret'])) {
|
if (!empty($params['webcron_secret'])) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protect directory from direct web access
|
/* Protect directory from direct web access */
|
||||||
$htaccess = $backupDir . '/.htaccess';
|
$htaccess = $backupDir . '/.htaccess';
|
||||||
|
|
||||||
if (!file_exists($htaccess)) {
|
if (!file_exists($htaccess)) {
|
||||||
@@ -361,7 +361,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Check if a MokoSuiteBackup task already exists
|
/* Check if a MokoSuiteBackup task already exists */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select('COUNT(*)')
|
->select('COUNT(*)')
|
||||||
->from($db->quoteName('#__scheduler_tasks'))
|
->from($db->quoteName('#__scheduler_tasks'))
|
||||||
@@ -460,7 +460,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
try {
|
try {
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|
||||||
// Find the parent menu item for our component
|
/* Find the parent menu item for our component */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select([$db->quoteName('id'), $db->quoteName('menutype')])
|
->select([$db->quoteName('id'), $db->quoteName('menutype')])
|
||||||
->from($db->quoteName('#__menu'))
|
->from($db->quoteName('#__menu'))
|
||||||
@@ -476,7 +476,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the component extension_id
|
/* Get the component extension_id */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select($db->quoteName('extension_id'))
|
->select($db->quoteName('extension_id'))
|
||||||
->from($db->quoteName('#__extensions'))
|
->from($db->quoteName('#__extensions'))
|
||||||
@@ -492,7 +492,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($submenus as $submenu) {
|
foreach ($submenus as $submenu) {
|
||||||
// Check if this submenu item already exists
|
/* Check if this submenu item already exists */
|
||||||
$query = $db->getQuery(true)
|
$query = $db->getQuery(true)
|
||||||
->select([$db->quoteName('id'), $db->quoteName('params')])
|
->select([$db->quoteName('id'), $db->quoteName('params')])
|
||||||
->from($db->quoteName('#__menu'))
|
->from($db->quoteName('#__menu'))
|
||||||
@@ -503,7 +503,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
$existing = $db->loadObject();
|
$existing = $db->loadObject();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Merge menu_icon into existing params to preserve other settings
|
/* Merge menu_icon into existing params to preserve other settings */
|
||||||
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
|
$existingParams = json_decode($existing->params ?? '{}', true) ?: [];
|
||||||
$existingParams['menu_icon'] = $submenu['menu_icon'];
|
$existingParams['menu_icon'] = $submenu['menu_icon'];
|
||||||
$mergedParams = json_encode($existingParams);
|
$mergedParams = json_encode($existingParams);
|
||||||
@@ -517,7 +517,7 @@ class Pkg_MokoSuiteBackupInstallerScript
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use Joomla's MenuTable to create the item properly
|
/* Use Joomla's MenuTable to create the item properly */
|
||||||
$table = Factory::getApplication()
|
$table = Factory::getApplication()
|
||||||
->bootComponent('com_menus')
|
->bootComponent('com_menus')
|
||||||
->getMVCFactory()
|
->getMVCFactory()
|
||||||
|
|||||||
Reference in New Issue
Block a user