diff --git a/.mokogitea/workflows/auto-release.yml b/.mokogitea/workflows/auto-release.yml index 3be5d44..ce90d46 100644 --- a/.mokogitea/workflows/auto-release.yml +++ b/.mokogitea/workflows/auto-release.yml @@ -27,9 +27,18 @@ name: "Universal: Build & Release" on: pull_request: - types: [opened, closed] + types: [opened, synchronize, closed] branches: - main + paths-ignore: + - '.mokogitea/workflows/**' + - '*.md' + - 'wiki/**' + - '.editorconfig' + - '.gitignore' + - '.gitattributes' + - '.gitmessage' + - 'LICENSE' workflow_dispatch: inputs: action: @@ -57,6 +66,7 @@ jobs: runs-on: release if: >- (github.event.action == 'opened' && github.event.pull_request.merged != true) || + (github.event.action == 'synchronize' && github.event.pull_request.merged != true) || (github.event_name == 'workflow_dispatch' && inputs.action == 'promote-rc') steps: diff --git a/.mokogitea/workflows/composer-publish.yml b/.mokogitea/workflows/composer-publish.yml deleted file mode 100644 index 03735c9..0000000 --- a/.mokogitea/workflows/composer-publish.yml +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (C) 2026 Moko Consulting -# 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 diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 20d4e73..7a8c126 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.39.02 +# VERSION: 01.42.01 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9803a10..b147687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,131 @@ # Changelog + ## [Unreleased] -## [01.27.03] --- 2026-06-21 +## [01.42.00] --- 2026-06-23 -## [01.27.03] --- 2026-06-21 -## [01.27.00] --- 2026-06-21 +## [01.42.00] --- 2026-06-23 -## [01.27.00] --- 2026-06-21 +## [01.41.00] — 2026-06-23 -## [01.27.00] --- 2026-06-21 +### 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 -## [01.27.00] --- 2026-06-21 +### 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 +- Initial release: full-site backup and restore for Joomla 6 +- Database, files, and configuration backup +- ZIP and tar.gz archive formats with AES-256 encryption +- Differential backups based on file manifests +- FTP/FTPS, S3, Google Drive remote storage +- MokoRestore standalone restore wizard +- CLI backup and restore commands +- REST API for remote management +- Scheduled tasks via com_scheduler +- Email and ntfy push notifications +- Per-profile retention, exclusions, and notifications +- Akeeba Backup migration tool +- Admin dashboard with system health checks diff --git a/README.md b/README.md index a7f4b6b..edf4659 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,80 @@ # MokoSuiteBackup - - Full-site backup and restore for Joomla — database, files, and configuration. -## Overview - -MokoSuiteBackup is a comprehensive backup solution for Joomla 4/5/6 sites. It creates complete site backups including the database, files, and configuration, packaged into downloadable ZIP archives. Supports multiple backup profiles, scheduled backups via CLI/cron, and a REST API for remote management. +| Field | Value | +|---|---| +| **Package** | `pkg_mokosuitebackup` | +| **Type** | Joomla Package (8 sub-extensions) | +| **Joomla** | 6.x+ | +| **PHP** | 8.1+ | +| **License** | GPL-3.0-or-later | ## Features -- Full site backup (database + files + configuration) -- Database-only backup mode -- Files-only backup mode -- Multiple backup profiles with independent configurations -- File and directory exclusion filters -- Table exclusion filters for database backups -- Step-based backup engine (avoids PHP timeout on large sites) -- CLI script for cron/scheduled backups -- REST API (Joomla Web Services) for remote management -- Backup record management (list, download, delete) -- Automatic old backup cleanup (configurable retention) -- Admin dashboard with backup history and storage usage +### Backup +- Full site, database-only, files-only, and differential backup modes +- Pre-flight validation — checks directory, disk space, extensions, credentials before starting +- Auto-verify archive integrity after creation +- Stepped AJAX engine prevents timeout on shared hosting +- AES-256 ZIP encryption with configurable password +- Configurable archive naming with placeholders ([HOST], [DATE], [SITE_NAME], etc.) +- Data sanitization — optionally clear user passwords, emails, and sessions in backup + +### Content Snapshots +- Lightweight JSON snapshots of articles, categories, and modules +- Includes tags, custom fields, workflow associations +- Restore modes: Replace (clean slate) or Merge (upsert) +- Selective article restore — browse and pick individual items +- Automatic retention (max count + max age) +- Scheduled snapshot task via com_scheduler + +### Remote Storage +- SFTP with SSH key file authentication (key stored base64-encoded in database) +- Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO) +- Google Drive with OAuth2 and resumable uploads +- Graceful degradation — local backup preserved if upload fails + +### MokoRestore Standalone Wizard +- 9-step restore wizard that works without Joomla installed +- Per-table conflict resolution: Replace / Skip / Merge / Data Only +- Post-restore actions: reset passwords, hits, versions, sessions, cache +- Auto-detect sanitized passwords and prompt for reset +- Standalone mode: restore.php scans directory for ZIP files +- Wrapped mode: restore.php bundled inside backup ZIP +- Security gate with filesystem verification + +### Notifications +- Email on success/failure per profile +- ntfy push notifications +- Notifications for restore and snapshot operations + +### Admin Dashboard +- Last backup status, next scheduled, total count, storage used +- Snapshot widget with latest info and type badges +- 30-day backup trend chart +- Per-profile storage breakdown +- System health checks + +### CLI +- `mokosuitebackup:run --profile=1` — run backup +- `mokosuitebackup:restore 1 --files-only --db-only --password=xxx` +- `mokosuitebackup:snapshot create|restore|list|delete` + +### REST API +- Backup: start, list, download, delete, profiles +- Snapshots: create, list, restore, delete, download +- Profile credentials masked in API responses ## Installation -1. Download `pkg_mokobackup-*.zip` from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases) +1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases) 2. Joomla Administrator > Extensions > Install -3. System plugin enabled automatically on install +3. Components > MokoSuiteBackup > Dashboard -## Configuration +## Documentation -- **Component**: Administrator > Components > MokoSuiteBackup -- **Profiles**: Create backup profiles with different file/database filters -- **System Plugin**: Configure scheduled backup triggers and notifications -- **CLI**: `php cli/mokobackup.php --profile=1` for cron-based backups - -## REST API - -The webservices plugin exposes endpoints compatible with the MokoBackup MCP server: - -- `POST /api/index.php/v1/mokobackup/backup` — Start a backup -- `GET /api/index.php/v1/mokobackup/backups` — List backup records -- `GET /api/index.php/v1/mokobackup/backup/:id/download` — Download archive -- `DELETE /api/index.php/v1/mokobackup/backup/:id` — Delete backup record -- `GET /api/index.php/v1/mokobackup/profiles` — List backup profiles +See the [Wiki](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/wiki) for guides and reference. ## License diff --git a/services/index.html b/services/index.html deleted file mode 100644 index 2efb97f..0000000 --- a/services/index.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/packages/com_mokosuitebackup/access.xml b/source/packages/com_mokosuitebackup/access.xml index 53fcc84..37c2f9d 100644 --- a/source/packages/com_mokosuitebackup/access.xml +++ b/source/packages/com_mokosuitebackup/access.xml @@ -12,5 +12,8 @@ + + + diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php index 16f1473..2cc5ebe 100644 --- a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php @@ -124,6 +124,7 @@ class BackupsController extends ApiController // Strip sensitive credentials before serialization $sensitiveFields = [ 'ftp_password', 'ftp_username', + 'sftp_password', 'sftp_key_data', 'sftp_passphrase', 's3_access_key', 's3_secret_key', 'gdrive_client_secret', 'gdrive_refresh_token', 'encryption_password', 'ntfy_token', diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php new file mode 100644 index 0000000..0d77d96 --- /dev/null +++ b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php @@ -0,0 +1,307 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * REST API controller for content snapshot operations. + * + * Endpoints: + * GET /api/index.php/v1/mokosuitebackup/snapshots — List snapshots + * POST /api/index.php/v1/mokosuitebackup/snapshot — Create snapshot + * POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore — Restore snapshot + * DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id — Delete snapshot + * GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download — Download snapshot JSON + */ + +namespace Joomla\Component\MokoSuiteBackup\Api\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Controller\ApiController; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; + +class SnapshotsController extends ApiController +{ + protected $contentType = 'snapshots'; + protected $default_view = 'snapshots'; + + /** + * List all snapshots with pagination (GET /api/index.php/v1/mokosuitebackup/snapshots) + */ + public function displayList(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $db = Factory::getDbo(); + + $limit = $this->input->getInt('limit', 20); + $offset = $this->input->getInt('offset', 0); + + // Clamp limits + $limit = max(1, min($limit, 100)); + $offset = max(0, $offset); + + // Get total count + $countQuery = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_snapshots')); + $db->setQuery($countQuery); + $total = (int) $db->loadResult(); + + // Get paginated results + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->order($db->quoteName('created') . ' DESC'); + $db->setQuery($query, $offset, $limit); + $items = $db->loadObjectList() ?: []; + + $data = []; + + foreach ($items as $item) { + $data[] = [ + 'type' => 'snapshots', + 'id' => $item->id, + 'attributes' => $item, + ]; + } + + $this->app->setHeader('status', 200); + echo json_encode([ + 'data' => $data, + 'meta' => [ + 'total' => $total, + 'limit' => $limit, + 'offset' => $offset, + ], + ]); + $this->app->close(); + + return $this; + } + + /** + * Create a new content snapshot (POST /api/index.php/v1/mokosuitebackup/snapshot) + */ + public function create(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $data = json_decode($this->input->json->getRaw(), true) ?: []; + + $contentTypes = $data['content_types'] ?? []; + $description = $data['description'] ?? ''; + + if (empty($contentTypes) || !is_array($contentTypes)) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'content_types array is required']]]); + $this->app->close(); + + return $this; + } + + $engine = new SnapshotEngine(); + $result = $engine->create($contentTypes, $description); + + if ($result['success']) { + $this->app->setHeader('status', 200); + echo json_encode(['data' => $result]); + } else { + $this->app->setHeader('status', 500); + echo json_encode(['errors' => [['title' => $result['message']]]]); + } + + $this->app->close(); + + return $this; + } + + /** + * Restore from a snapshot (POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore) + */ + public function restore(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $id = $this->input->getInt('id', 0); + + if (!$id) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]); + $this->app->close(); + + return $this; + } + + $data = json_decode($this->input->json->getRaw(), true) ?: []; + + $mode = $data['mode'] ?? 'replace'; + $contentTypes = $data['content_types'] ?? []; + + // Enforce valid restore mode + if (!in_array($mode, ['replace', 'merge'], true)) { + $mode = 'replace'; + } + + $engine = new SnapshotRestoreEngine(); + $result = $engine->restore($id, $mode, $contentTypes); + + if ($result['success']) { + $this->app->setHeader('status', 200); + echo json_encode(['data' => $result]); + } else { + $this->app->setHeader('status', 500); + echo json_encode(['errors' => [['title' => $result['message']]]]); + } + + $this->app->close(); + + return $this; + } + + /** + * Delete a snapshot record and its data file (DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id) + */ + public function delete(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $id = $this->input->getInt('id', 0); + + if (!$id) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]); + $this->app->close(); + + return $this; + } + + $db = Factory::getDbo(); + + // Load record to get file path + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + $this->app->setHeader('status', 404); + echo json_encode(['errors' => [['title' => 'Snapshot not found']]]); + $this->app->close(); + + return $this; + } + + // Delete data file + if ($record->data_file && is_file($record->data_file)) { + if (!unlink($record->data_file)) { + error_log('MokoSuiteBackup: Failed to delete snapshot file: ' . $record->data_file); + } + } + + // Delete record + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $db->execute(); + + $this->app->setHeader('status', 200); + echo json_encode(['data' => ['success' => true, 'message' => 'Snapshot deleted']]); + $this->app->close(); + + return $this; + } + + /** + * Stream the JSON snapshot file (GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download) + */ + public function download(): static + { + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Access denied']]]); + $this->app->close(); + + return $this; + } + + $id = $this->input->getInt('id', 0); + + if (!$id) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'Snapshot ID is required']]]); + $this->app->close(); + + return $this; + } + + $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 || !is_file($record->data_file) || !is_readable($record->data_file)) { + $this->app->setHeader('status', 404); + echo json_encode(['errors' => [['title' => 'Snapshot file not found']]]); + $this->app->close(); + + return $this; + } + + // Stream as download + while (@ob_end_clean()) { + // clear all buffers + } + + $filename = basename($record->data_file); + $filesize = filesize($record->data_file); + + header('Content-Type: application/json'); + header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename)); + header('Content-Length: ' . $filesize); + header('Cache-Control: no-cache, must-revalidate'); + + readfile($record->data_file); + + $this->app->close(); + + return $this; + } +} diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 60428d2..ff63899 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -39,6 +39,73 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+ + + +
+
+ - - + + +
+
+ + + + + + + + + + + + + + + + +
+
+ - + @@ -174,6 +232,81 @@ + + + + + + + + + + + + + +
diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index bf9735b..8942f04 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -33,6 +33,12 @@ COM_MOKOJOOMBACKUP_DASHBOARD_QUICK_ACTIONS="Quick Actions" COM_MOKOJOOMBACKUP_DASHBOARD_SCHEDULED_TASKS="Scheduled Tasks" COM_MOKOJOOMBACKUP_DASHBOARD_UPDATE_SITE="Update Site" COM_MOKOJOOMBACKUP_DASHBOARD_SYSTEM_HEALTH="System Health" +COM_MOKOJOOMBACKUP_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 COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" @@ -44,6 +50,22 @@ COM_MOKOJOOMBACKUP_DOWNLOAD="Download" ; Backup detail view COM_MOKOJOOMBACKUP_BACKUP_DETAIL="Backup Detail" 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_PATH="File Path" 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_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 COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description" COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile" @@ -91,6 +119,7 @@ COM_MOKOJOOMBACKUP_FIELD_TABLES_COUNT="Tables Count" ; Archive settings COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT="Archive Format" COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_FORMAT_DESC="Format for the backup archive file" +COM_MOKOJOOMBACKUP_FORMAT_7Z="7z (requires 7za CLI)" COM_MOKOJOOMBACKUP_FIELD_COMPRESSION="Compression Level" COM_MOKOJOOMBACKUP_FIELD_COMPRESSION_DESC="Higher compression = smaller file but slower" COM_MOKOJOOMBACKUP_COMPRESSION_NONE="None (fastest)" @@ -98,15 +127,29 @@ COM_MOKOJOOMBACKUP_COMPRESSION_FASTEST="Low (fast)" COM_MOKOJOOMBACKUP_COMPRESSION_NORMAL="Normal (balanced)" COM_MOKOJOOMBACKUP_COMPRESSION_BEST="Maximum (smallest)" COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD="Encryption Password" -COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the backup archive with AES-256. Leave blank for no encryption. Required to restore encrypted backups." +COM_MOKOJOOMBACKUP_FIELD_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_DESC="Split archive into parts of this size in MB. 0 = no splitting." COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory" -COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root." +COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="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_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]." -COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script" -COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed." +COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template (without extension). Click the placeholder buttons below to insert tokens." +COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script" +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 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." ; S3 storage +COM_MOKOJOOMBACKUP_REMOTE_SFTP="SFTP (SSH File Transfer)" 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_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." @@ -269,6 +340,13 @@ COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup comple COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure" COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)." +; Snapshot Retention +COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_RETENTION="Snapshot Retention" +COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT="Max Snapshot Count" +COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT_DESC="Maximum number of content snapshots to keep. Oldest are removed first. Set to 0 for unlimited." +COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE="Max Snapshot Age (days)" +COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE_DESC="Delete snapshots older than this many days. Set to 0 for unlimited." + ; Web Cron COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron" COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron" @@ -336,6 +414,38 @@ COM_MOKOJOOMBACKUP_SNAPSHOTS_N_DELETED="%d snapshot(s) deleted." COM_MOKOJOOMBACKUP_SNAPSHOTS_1_DELETED="1 snapshot deleted." 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 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." @@ -358,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_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 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." diff --git a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini index b2a7fbe..ef2e72b 100644 --- a/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-US/com_mokosuitebackup.ini @@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles" COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now" COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found." +COM_MOKOJOOMBACKUP_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 Update Site with your download key." 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 Update Site to receive automatic updates." @@ -77,9 +81,38 @@ COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DATA="Data" COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_STRUCTURE="Structure" COM_MOKOJOOMBACKUP_FIELD_TABLE_NAME="Table Name" COM_MOKOJOOMBACKUP_VIEW_LOG="Backup Log" +COM_MOKOJOOMBACKUP_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_PATH="File Path" COM_MOKOJOOMBACKUP_FIELD_DB_SIZE="DB Size" COM_MOKOJOOMBACKUP_FIELD_REMOTE="Remote Path" COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS="Notify User Groups" COM_MOKOJOOMBACKUP_FIELD_NOTIFY_USER_GROUPS_DESC="Select Joomla user groups whose members will receive backup notifications. Combined with email addresses above." + +; 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." diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index 323c3f4..1e85d85 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> MokoSuiteBackup - 01.39.02 + 01.42.01 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 03e32bf..8767c04 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max', `split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part', `backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]', - `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders', + `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders', `exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude', `exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude', `exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude', @@ -19,6 +19,14 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `ftp_path` VARCHAR(512) NOT NULL DEFAULT '/backups', `ftp_passive` TINYINT(1) NOT NULL DEFAULT 1, `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_secret` VARCHAR(255) 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', `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', - `include_mokorestore` 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_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs', `notify_on_success` TINYINT(1) NOT NULL DEFAULT 0, @@ -95,6 +107,22 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_snapshots` ( KEY `idx_created` (`created`) ) 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 IGNORE INTO `#__mokosuitebackup_profiles` ( `id`, `title`, `description`, `backup_type`, diff --git a/source/packages/com_mokosuitebackup/sql/uninstall.mysql.sql b/source/packages/com_mokosuitebackup/sql/uninstall.mysql.sql index 241bd9b..2a39ae3 100644 --- a/source/packages/com_mokosuitebackup/sql/uninstall.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/uninstall.mysql.sql @@ -1,2 +1,3 @@ +DROP TABLE IF EXISTS `#__mokosuitebackup_remotes`; DROP TABLE IF EXISTS `#__mokosuitebackup_records`; DROP TABLE IF EXISTS `#__mokosuitebackup_profiles`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.01.02.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.01.02.sql index 9cc869f..4189b04 100644 --- a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.01.02.sql +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.01.02.sql @@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL; ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`; -- Add archive_name_format column with placeholder support -ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`; +ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.35.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.35.00.sql new file mode 100644 index 0000000..0fcc607 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.35.00.sql @@ -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`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.36.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.36.00.sql new file mode 100644 index 0000000..b8b6565 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.36.00.sql @@ -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`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.00.sql new file mode 100644 index 0000000..e7820b0 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.00.sql @@ -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'; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.01.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.01.sql new file mode 100644 index 0000000..c5a5f59 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.01.sql @@ -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]'; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.02.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.02.sql new file mode 100644 index 0000000..7d3d8fc --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.39.02.sql @@ -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`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.41.00.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.41.00.sql new file mode 100644 index 0000000..667bc53 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.41.00.sql @@ -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` != ''; diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 2cdbbb7..41c861a 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -15,9 +15,12 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\BaseController; use Joomla\CMS\Session\Session; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; class AjaxController extends BaseController @@ -282,7 +285,32 @@ class AjaxController extends BaseController return; } - $resolved = BackupDirectory::resolve($rawPath); + /* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR]) + and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */ + $profileId = $this->input->getInt('profile_id', 0); + + if ($profileId > 0) { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + } + + if (empty($profile)) { + /* No profile context — create a minimal dummy for PlaceholderResolver */ + $profile = (object) [ + 'id' => 1, + 'title' => 'default', + 'backup_type' => 'full', + ]; + } + + $resolver = new PlaceholderResolver($profile); + $withNamePlaceholders = $resolver->resolve($rawPath); + $resolved = BackupDirectory::resolve($withNamePlaceholders); if (BackupDirectory::hasPlaceholders($resolved)) { $this->sendJson([ @@ -308,6 +336,1056 @@ class AjaxController extends BaseController ]); } + /** + * Initialize a new stepped restore. + * POST: task=ajax.restoreInit&id=123&restore_files=1&restore_db=1&preserve_config=1&encryption_password= + */ + public function restoreInit(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $recordId = $this->input->getInt('id', 0); + $restoreFiles = (bool) $this->input->getInt('restore_files', 1); + $restoreDb = (bool) $this->input->getInt('restore_db', 1); + $preserveConfig = (bool) $this->input->getInt('preserve_config', 1); + $password = $this->input->getString('encryption_password', ''); + + if (!$recordId) { + $this->sendJson(['error' => true, 'message' => 'Missing record ID']); + + return; + } + + $engine = new SteppedRestoreEngine(); + $result = $engine->init($recordId, $restoreFiles, $restoreDb, $preserveConfig, $password); + + $this->sendJson($result); + } + + /** + * Run the next step of a restore session. + * POST: task=ajax.restoreStep&session_id=mb_... + */ + public function restoreStep(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.restore', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $sessionId = $this->input->getString('session_id', ''); + + if (empty($sessionId)) { + $this->sendJson(['error' => true, 'message' => 'Missing session_id']); + + return; + } + + $engine = new SteppedRestoreEngine(); + $result = $engine->runStep($sessionId); + + $this->sendJson($result); + } + + /** + * Browse archive contents without extracting. + * POST: task=ajax.browseArchive&id=123 + */ + public function browseArchive(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.browse', '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 record ID']); + + return; + } + + try { + $db = \Joomla\CMS\Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['absolute_path', 'status', 'filesexist'])) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . (int) $id); + $db->setQuery($query); + $record = $db->loadObject(); + } catch (\Exception $e) { + error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage()); + $this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500); + + return; + } + + if (!$record) { + $this->sendJson(['error' => true, 'message' => 'Record not found'], 404); + + return; + } + + if ($record->status !== 'complete' || !$record->filesexist) { + $this->sendJson(['error' => true, 'message' => 'Archive not available']); + + return; + } + + $archivePath = $record->absolute_path; + + if (!is_file($archivePath)) { + $this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']); + + return; + } + + $maxEntries = 500; + + try { + $files = []; + $totalFiles = 0; + $totalSize = 0; + $truncated = false; + + $lower = strtolower($archivePath); + + if (substr($lower, -4) === '.zip') { + $files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated); + } elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') { + $files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated); + } else { + $this->sendJson(['error' => true, 'message' => 'Unsupported archive format']); + + return; + } + } catch (\Exception $e) { + error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage()); + $this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]); + + return; + } + + $this->sendJson([ + 'error' => false, + 'files' => $files, + 'total_files' => $totalFiles, + 'total_size' => $totalSize, + 'truncated' => $truncated, + ]); + } + + /** + * Browse a ZIP archive and return file entries. + * + * @param string $path Absolute path to the ZIP file + * @param int $maxEntries Maximum entries to return + * @param int &$totalFiles Total number of files (by reference) + * @param int &$totalSize Total uncompressed size (by reference) + * @param bool &$truncated Whether results were truncated (by reference) + * + * @return array List of file entry arrays + */ + private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array + { + $zip = new \ZipArchive(); + + if ($zip->open($path, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Cannot open ZIP archive'); + } + + $files = []; + $totalFiles = $zip->numFiles; + + for ($i = 0; $i < $totalFiles; $i++) { + $stat = $zip->statIndex($i); + + if ($stat === false) { + continue; + } + + $totalSize += $stat['size']; + + if (\count($files) < $maxEntries) { + $files[] = [ + 'name' => $stat['name'], + 'size' => $stat['size'], + 'compressed_size' => $stat['comp_size'], + ]; + } + } + + $truncated = $totalFiles > $maxEntries; + $zip->close(); + + return $files; + } + + /** + * Browse a tar.gz archive and return file entries. + * + * @param string $path Absolute path to the tar.gz file + * @param int $maxEntries Maximum entries to return + * @param int &$totalFiles Total number of files (by reference) + * @param int &$totalSize Total uncompressed size (by reference) + * @param bool &$truncated Whether results were truncated (by reference) + * + * @return array List of file entry arrays + */ + private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array + { + $phar = new \PharData($path); + $files = []; + + foreach (new \RecursiveIteratorIterator($phar) as $entry) { + $totalFiles++; + $entrySize = $entry->getSize(); + $totalSize += $entrySize; + + if (\count($files) < $maxEntries) { + // Strip the phar:// prefix and archive path to get relative name + $fullPath = str_replace('\\', '/', $entry->getPathname()); + $relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath) + ?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath) + ?: $fullPath; + + $files[] = [ + 'name' => $relativeName, + 'size' => $entrySize, + 'compressed_size' => $entrySize, + ]; + } + } + + $truncated = $totalFiles > $maxEntries; + + return $files; + } + + /** + * Browse articles inside a snapshot — returns JSON list for the browse modal. + * POST: task=ajax.browseSnapshot&id=123 + */ + public function browseSnapshot(): 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 = \Joomla\CMS\Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_snapshots')) + ->where($db->quoteName('id') . ' = ' . (int) $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) { + $this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']); + + return; + } + + $tables = $data['tables'] ?? []; + + // Articles + $articles = []; + + if (!empty($tables['#__content'])) { + foreach ($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'] ?? '', + ]; + } + } + + // Categories + $categories = []; + + if (!empty($tables['#__categories'])) { + foreach ($tables['#__categories'] as $row) { + $categories[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $row['title'] ?? '', + 'extension' => $row['extension'] ?? '', + 'published' => (int) ($row['published'] ?? 0), + 'level' => (int) ($row['level'] ?? 0), + ]; + } + } + + // Modules + $modules = []; + + if (!empty($tables['#__modules'])) { + foreach ($tables['#__modules'] as $row) { + $modules[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'title' => $row['title'] ?? '', + 'module' => $row['module'] ?? '', + 'position' => $row['position'] ?? '', + 'published' => (int) ($row['published'] ?? 0), + ]; + } + } + + $this->sendJson([ + 'error' => false, + 'articles' => $articles, + 'categories' => $categories, + 'modules' => $modules, + 'total_articles' => \count($articles), + 'total_categories' => \count($categories), + 'total_modules' => \count($modules), + ]); + } + + /** + * Count backup records that would be purged before a given date. + * POST: task=ajax.countPurge&date=2025-01-01 + */ + public function countPurge(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.purge', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $date = $this->input->getString('date', ''); + + if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $this->sendJson(['error' => true, 'message' => 'Invalid date format. Expected YYYY-MM-DD.']); + + return; + } + + $cutoff = $date . ' 00:00:00'; + + try { + $db = \Joomla\CMS\Factory::getDbo(); + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('backupstart') . ' < ' . $db->quote($cutoff)) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')); + $db->setQuery($query); + $count = (int) $db->loadResult(); + } catch (\Exception $e) { + error_log('MokoSuiteBackup: countPurge() DB error: ' . $e->getMessage()); + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + + return; + } + + $this->sendJson([ + 'error' => false, + 'count' => $count, + 'date' => $date, + ]); + } + + /** + * Compare two backup records side-by-side. + * POST: task=ajax.compareBackups&id1=123&id2=456 + */ + public function compareBackups(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.compare', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id1 = $this->input->getInt('id1', 0); + $id2 = $this->input->getInt('id2', 0); + + if (!$id1 || !$id2) { + $this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']); + + return; + } + + if ($id1 === $id2) { + $this->sendJson(['error' => true, 'message' => 'Please select two different backup records']); + + return; + } + + $fields = [ + 'r.id', 'r.description', 'r.status', 'r.backup_type', + 'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count', + 'r.backupstart', 'r.backupend', + ]; + + try { + $db = \Joomla\CMS\Factory::getDbo(); + + $query = $db->getQuery(true) + ->select($db->quoteName($fields)) + ->select($db->quoteName('p.title', 'profile_title')) + ->from($db->quoteName('#__mokosuitebackup_records', 'r')) + ->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') + . ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id')) + ->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')'); + + $db->setQuery($query); + $rows = $db->loadObjectList('id'); + } catch (\Exception $e) { + error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage()); + $this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500); + + return; + } + + if (!isset($rows[$id1])) { + $this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404); + + return; + } + + if (!isset($rows[$id2])) { + $this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404); + + return; + } + + $b1 = $rows[$id1]; + $b2 = $rows[$id2]; + + // Calculate durations in seconds + $duration1 = 0; + $duration2 = 0; + + if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') { + $duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart); + } + + if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') { + $duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart); + } + + $formatRecord = function ($row) { + return [ + 'id' => (int) $row->id, + 'description' => $row->description, + 'status' => $row->status, + 'backup_type' => $row->backup_type, + 'total_size' => (int) $row->total_size, + 'db_size' => (int) $row->db_size, + 'files_count' => (int) $row->files_count, + 'tables_count' => (int) $row->tables_count, + 'backupstart' => $row->backupstart, + 'backupend' => $row->backupend, + 'profile_title' => $row->profile_title ?? '', + ]; + }; + + $this->sendJson([ + 'error' => false, + 'backup1' => $formatRecord($b1), + 'backup2' => $formatRecord($b2), + 'delta' => [ + 'size_diff' => (int) $b2->total_size - (int) $b1->total_size, + 'files_diff' => (int) $b2->files_count - (int) $b1->files_count, + 'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count, + 'duration_diff_seconds' => $duration2 - $duration1, + ], + ]); + } + + // ------------------------------------------------------------------ + // Remote Destinations CRUD + // ------------------------------------------------------------------ + + /** + * List remote destinations for a profile. + * POST: task=ajax.listRemotes&profile_id=1 + */ + public function listRemotes(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $profileId = $this->input->getInt('profile_id', 0); + + if (!$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . $profileId) + ->order($db->quoteName('ordering') . ' ASC, ' . $db->quoteName('id') . ' ASC'); + $db->setQuery($query); + $rows = $db->loadObjectList(); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + + return; + } + + // Decode JSON config and mask secrets + $items = []; + + foreach ($rows as $row) { + $config = json_decode($row->config, true) ?: []; + + // Mask sensitive fields so they never leave the server in list views + $masked = $this->maskSecrets($config, $row->type); + + $items[] = [ + 'id' => (int) $row->id, + 'profile_id' => (int) $row->profile_id, + 'title' => $row->title, + 'type' => $row->type, + 'enabled' => (int) $row->enabled, + 'keep_local' => (int) $row->keep_local, + 'config' => $masked, + 'ordering' => (int) $row->ordering, + ]; + } + + $this->sendJson(['error' => false, 'items' => $items]); + } + + /** + * Save (create or update) a remote destination. + * POST: task=ajax.saveRemote (JSON body or form fields) + */ + public function saveRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + $title = trim($this->input->getString('remote_title', '')); + $type = $this->input->getCmd('remote_type', 'sftp'); + $enabled = $this->input->getInt('remote_enabled', 1); + $keepLocal = $this->input->getInt('remote_keep_local', 1); + $configRaw = $this->input->getString('remote_config', '{}'); + + if (!$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); + + return; + } + + if (empty($title)) { + $this->sendJson(['error' => true, 'message' => 'Title is required']); + + return; + } + + $config = json_decode($configRaw, true); + + if (!is_array($config)) { + $this->sendJson(['error' => true, 'message' => 'Invalid config JSON']); + + return; + } + + // If editing, merge secrets that were masked with __KEEP_EXISTING__ + if ($id) { + $config = $this->mergeExistingSecrets($id, $config, $type); + } + + $db = Factory::getDbo(); + + try { + $table = new \Joomla\Component\MokoSuiteBackup\Administrator\Table\RemoteTable($db); + + if ($id) { + $table->load($id); + + // Verify ownership + if ((int) $table->profile_id !== $profileId) { + $this->sendJson(['error' => true, 'message' => 'Remote does not belong to this profile'], 403); + + return; + } + } + + $table->profile_id = $profileId; + $table->title = $title; + $table->type = $type; + $table->enabled = $enabled ? 1 : 0; + $table->keep_local = $keepLocal ? 1 : 0; + $table->config = json_encode($config); + + if (!$table->check() || !$table->store()) { + $this->sendJson(['error' => true, 'message' => $table->getError() ?: 'Save failed']); + + return; + } + + $this->sendJson(['error' => false, 'id' => (int) $table->id, 'message' => 'Saved']); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error: ' . $e->getMessage()], 500); + } + } + + /** + * Delete a remote destination. + * POST: task=ajax.deleteRemote&remote_id=1&profile_id=1 + */ + public function deleteRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + + if (!$id || !$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($query); + $db->execute(); + + $this->sendJson(['error' => false, 'message' => 'Deleted']); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + } + } + + /** + * Toggle enabled/disabled for a remote destination. + * POST: task=ajax.toggleRemote&remote_id=1&profile_id=1 + */ + public function toggleRemote(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $id = $this->input->getInt('remote_id', 0); + $profileId = $this->input->getInt('profile_id', 0); + + if (!$id || !$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing remote_id or profile_id']); + + return; + } + + try { + $db = Factory::getDbo(); + + // Load current state + $query = $db->getQuery(true) + ->select($db->quoteName('enabled')) + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($query); + $current = $db->loadResult(); + + if ($current === null) { + $this->sendJson(['error' => true, 'message' => 'Remote not found'], 404); + + return; + } + + $newState = $current ? 0 : 1; + + $update = $db->getQuery(true) + ->update($db->quoteName('#__mokosuitebackup_remotes')) + ->set($db->quoteName('enabled') . ' = ' . $newState) + ->set($db->quoteName('modified') . ' = ' . $db->quote(date('Y-m-d H:i:s'))) + ->where($db->quoteName('id') . ' = ' . $id) + ->where($db->quoteName('profile_id') . ' = ' . $profileId); + $db->setQuery($update); + $db->execute(); + + $this->sendJson(['error' => false, 'enabled' => $newState]); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Database error'], 500); + } + } + + /** + * Mask sensitive values in a remote config array for display. + */ + private function maskSecrets(array $config, string $type): array + { + $secrets = [ + 'sftp' => ['password', 'passphrase', 'key_data'], + 's3' => ['secret_key'], + 'google_drive' => ['client_secret', 'refresh_token'], + ]; + + $fields = $secrets[$type] ?? []; + + foreach ($fields as $field) { + if (!empty($config[$field])) { + $config[$field] = '********'; + } + } + + return $config; + } + + /** + * When updating a remote, merge back secrets that were masked in the form. + */ + private function mergeExistingSecrets(int $id, array $config, string $type): array + { + $secrets = [ + 'sftp' => ['password', 'passphrase', 'key_data'], + 's3' => ['secret_key'], + 'google_drive' => ['client_secret', 'refresh_token'], + ]; + + $fields = $secrets[$type] ?? []; + $needsMerge = false; + + foreach ($fields as $field) { + if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) { + $needsMerge = true; + + break; + } + } + + if (!$needsMerge) { + return $config; + } + + // Load existing config from DB + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName('config')) + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('id') . ' = ' . $id); + $db->setQuery($query); + $existing = json_decode($db->loadResult() ?: '{}', true) ?: []; + } catch (\Exception $e) { + return $config; + } + + foreach ($fields as $field) { + if (isset($config[$field]) && ($config[$field] === '********' || $config[$field] === '__KEEP_EXISTING__')) { + $config[$field] = $existing[$field] ?? ''; + } + } + + return $config; + } + + /** + * Browse directories on a remote SFTP server for the path picker. + * POST: task=ajax.browseSftpDir&profile_id=1&path=/some/path + */ + public function browseSftpDir(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + $profileId = $this->input->getInt('profile_id', 0); + + if (!$profileId) { + $this->sendJson(['error' => true, 'message' => 'Missing profile_id']); + + return; + } + + /* Load the profile to get SFTP credentials */ + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + } catch (\Exception $e) { + $this->sendJson(['error' => true, 'message' => 'Failed to load profile'], 500); + + return; + } + + if (!$profile) { + $this->sendJson(['error' => true, 'message' => 'Profile not found'], 404); + + return; + } + + $host = $profile->sftp_host ?? ''; + $port = (int) ($profile->sftp_port ?? 22); + $username = $profile->sftp_username ?? ''; + $keyData = $profile->sftp_key_data ?? ''; + $password = $profile->sftp_password ?? ''; + + if (empty($host) || empty($username)) { + $this->sendJson(['error' => true, 'message' => 'SFTP host and username must be configured and saved before browsing']); + + return; + } + + if (empty($keyData) && empty($password)) { + $this->sendJson(['error' => true, 'message' => 'SFTP credentials (key or password) must be configured and saved before browsing']); + + return; + } + + $requestPath = $this->input->getString('path', '/'); + + /* Sanitize: must start with / and not contain shell meta-characters */ + $requestPath = '/' . ltrim($requestPath, '/'); + + if (preg_match('/[;&|`$<>]/', $requestPath)) { + $this->sendJson(['error' => true, 'message' => 'Invalid path characters']); + + return; + } + + $keyFile = null; + + try { + /* Write temp key if using key auth (same pattern as SftpUploader) */ + if (!empty($keyData)) { + $keyContent = base64_decode($keyData, true); + + if ($keyContent === false) { + $keyContent = $keyData; + } + + $keyFile = sys_get_temp_dir() . '/mokobackup-sftp-browse-' . bin2hex(random_bytes(8)) . '.key'; + + if (file_put_contents($keyFile, $keyContent) === false) { + throw new \RuntimeException('Cannot write temporary SSH key file'); + } + + chmod($keyFile, 0600); + } + + /* Build SSH command to list directories */ + $escapedPath = escapeshellarg($requestPath); + $remoteCmd = 'ls -1pa ' . $escapedPath . ' 2>/dev/null | grep "/$"'; + + $parts = ['ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10']; + + if ($port !== 22) { + $parts[] = '-p'; + $parts[] = (string) $port; + } + + if ($keyFile !== null) { + $parts[] = '-i'; + $parts[] = escapeshellarg($keyFile); + } + + $parts[] = escapeshellarg($username . '@' . $host); + $parts[] = escapeshellarg($remoteCmd); + + $cmd = implode(' ', $parts); + + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + /* exitCode 1 from grep means no matches (empty dir), which is OK */ + if ($exitCode !== 0 && $exitCode !== 1) { + throw new \RuntimeException('SSH command failed (exit ' . $exitCode . '): ' . implode(' ', $output)); + } + + /* Parse output: each line is a directory name ending with / */ + $dirs = []; + + foreach ($output as $line) { + $line = trim($line); + + if ($line === '' || $line === './' || $line === '../') { + continue; + } + + $dirName = rtrim($line, '/'); + + if ($dirName === '' || $dirName === '.' || $dirName === '..') { + continue; + } + + $fullPath = rtrim($requestPath, '/') . '/' . $dirName; + + $dirs[] = [ + 'name' => $dirName, + 'path' => $fullPath, + ]; + } + + usort($dirs, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + + /* Parent path */ + $parent = null; + + if ($requestPath !== '/') { + $parent = \dirname($requestPath); + + if ($parent === '') { + $parent = '/'; + } + } + + $this->sendJson([ + 'error' => false, + 'current' => $requestPath, + 'parent' => $parent, + 'dirs' => $dirs, + ]); + } catch (\Throwable $e) { + $this->sendJson(['error' => true, 'message' => 'SFTP browse failed: ' . $e->getMessage()]); + } finally { + if ($keyFile !== null && is_file($keyFile)) { + unlink($keyFile); + } + } + } + /** * Send a JSON response and close the application. */ diff --git a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php index 5e1e9e6..1f09d49 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php @@ -15,6 +15,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine; @@ -34,7 +35,14 @@ class BackupsController extends AdminController */ 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')) { $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)); } + /** + * 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. */ diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php index 3aa1678..f72ff53 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -16,6 +16,7 @@ use Joomla\CMS\Factory; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; 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)); } + /** + * 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. */ @@ -113,7 +259,7 @@ class SnapshotsController extends AdminController { $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->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index fb09db5..52f0a58 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -87,8 +87,14 @@ class BackupEngine $archiveFormat = $profile->archive_format ?? 'zip'; $archiveName = ''; $archiver = $this->createArchiver($archiveFormat); + + // Pass encryption password to 7z archiver (handles it natively via -p flag) + if ($archiver instanceof SevenZipArchiver && !empty($profile->encryption_password)) { + $archiver->setEncryptionPassword($profile->encryption_password); + } + $archiveExt = $archiver->getExtension(); - $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]'; $archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt; if (empty($description)) { @@ -137,7 +143,19 @@ class BackupEngine if ($profile->backup_type !== 'files') { $this->log('Starting database dump...'); $sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql'; - $dumper = new DatabaseDumper($excludeTables); + $sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false); + $preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false); + $sanitizeEmails = (bool) ($profile->sanitize_emails ?? false); + $sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true); + $dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions); + + if ($sanitizePasswords) { + $this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : '')); + } + + if ($sanitizeEmails) { + $this->log('User emails will be sanitized'); + } $dbSize = $dumper->dumpToFile($sqlTempFile); $archiver->addFile($sqlTempFile, 'database.sql'); $tablesCount = $dumper->getTablesCount(); @@ -216,12 +234,14 @@ class BackupEngine $encryptionPassword = $profile->encryption_password ?? ''; if (!empty($encryptionPassword)) { - if ($archiveFormat !== 'zip') { - $this->log('WARNING: AES-256 encryption only supported for ZIP archives — skipping encryption'); - } else { + if ($archiveFormat === 'zip') { $this->log('Encrypting archive with AES-256...'); $this->encryptArchive($archivePath, $encryptionPassword); $this->log('Archive encrypted'); + } elseif ($archiveFormat === '7z') { + $this->log('Archive encrypted with AES-256 (7z native encryption)'); + } else { + $this->log('WARNING: AES-256 encryption only supported for ZIP and 7z archives — skipping encryption'); } } @@ -232,56 +252,123 @@ class BackupEngine $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); - // Step 2.5: Wrap with MokoRestore script (if enabled) - $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); + // Verify archive integrity + $this->log('Verifying archive integrity...'); + $this->verifyArchive($archivePath, $profile->backup_type); + $this->log('Archive integrity verified'); - if ($includeMokoRestore) { + // Step 2.5: MokoRestore script (if enabled) + $mokoRestoreMode = $profile->include_mokorestore ?? '0'; + $restoreScriptPath = ''; + + if ($mokoRestoreMode === '1') { + // Wrapped mode: backup ZIP inside an outer ZIP with restore.php $this->log('Wrapping with MokoRestore script...'); $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; MokoRestore::wrap($archivePath, $mokoRestorePath); - // Replace the original archive with the wrapped one if (is_file($archivePath) && !unlink($archivePath)) { $this->log('WARNING: Could not remove pre-wrap archive'); } rename($mokoRestorePath, $archivePath); $totalSize = filesize($archivePath); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; - // Recompute checksum for the final wrapped archive $checksum = hash_file('sha256', $archivePath); $this->log('MokoRestore archive created: ' . $sizeHuman); $this->log('SHA-256 (wrapped): ' . $checksum); + } 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 = ''; + $uploadFailed = false; - // Step 3: Remote upload (if configured) - $remoteStorage = $profile->remote_storage ?? 'none'; + /* Step 3: Remote upload — iterate all enabled destinations */ + $remotes = $this->loadRemoteDestinations($db, $profileId); - if ($remoteStorage !== 'none') { - $this->log('Starting remote upload (' . $remoteStorage . ')...'); - $uploader = $this->createUploader($remoteStorage, $profile); - $uploadResult = $uploader->upload($archivePath, $archiveName); + if (!empty($remotes)) { + foreach ($remotes as $remote) { + try { + $this->log('Uploading to: ' . $remote->title . ' (' . $remote->type . ')...'); + $params = json_decode($remote->params, true) ?: []; + $uploader = $this->createUploaderFromParams($remote->type, $params); + $result = $uploader->upload($archivePath, $archiveName); - if ($uploadResult['success']) { - $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; - $this->log('Remote upload complete: ' . $uploadResult['message']); + if ($result['success']) { + $remoteFilename = $result['remote_path'] ?? $archiveName; + $this->log(' Upload complete: ' . $result['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)'); + /* Upload standalone restore.php if in standalone mode */ + if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { + $uploader->upload($restoreScriptPath, 'restore.php'); + } + } else { + $uploadFailed = true; + $this->log(' WARNING: Upload failed: ' . $result['message']); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $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.'); } - } else { - $this->log('WARNING: Remote upload failed: ' . $uploadResult['message']); - $this->log('Local backup is preserved.'); } } // Write log file alongside the archive $logContent = implode("\n", $this->log); - $logPath = preg_replace('/\.(zip|tar\.gz)$/i', '.log', $archivePath); + $logPath = preg_replace('/\.(zip|tar\.gz|7z)$/i', '.log', $archivePath); if (@file_put_contents($logPath, $logContent) === false) { error_log('MokoSuiteBackup: Could not write log file: ' . $logPath); } @@ -309,9 +396,14 @@ class BackupEngine $db->updateObject('#__mokosuitebackup_records', $update, 'id'); - // Send success notification + // Send success notification (backup completed, even if upload failed) NotificationSender::send($profile, $update, true, implode("\n", $this->log)); + // If remote upload failed, also send a failure notification for the upload + if ($uploadFailed) { + NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log)); + } + // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin); @@ -422,23 +514,80 @@ class BackupEngine return match ($format) { 'zip' => new ZipArchiver(), 'tar.gz' => new TarGzArchiver(), + '7z' => new SevenZipArchiver(), default => throw new \InvalidArgumentException('Unknown archive format: ' . $format), }; } /** * 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 { return match ($type) { 'ftp' => new FtpUploader($profile), + 'sftp' => new SftpUploader($profile), 'google_drive' => new GoogleDriveUploader($profile), 's3' => new S3Uploader($profile), 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. * Used by differential backups to determine which files changed. @@ -503,6 +652,155 @@ class BackupEngine $zip->close(); } + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); + } + + $extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION)); + + // Detect tar.gz (pathinfo only returns 'gz') + if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) { + $this->verifyTarGzArchive($archivePath); + + return; + } + + // 7z verification via CLI + if ($extension === '7z') { + $this->verify7zArchive($archivePath); + + return; + } + + // ZIP verification + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } + + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); + } + + /** + * Verify a tar.gz archive can be opened and iterated. + * + * @param string $archivePath Absolute path to the .tar.gz file + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyTarGzArchive(string $archivePath): void + { + try { + $phar = new \PharData($archivePath); + $count = 0; + + foreach ($phar as $entry) { + // Spot-check: verify at least the first entry is accessible + $entry->getFilename(); + $count++; + break; + } + + if ($count === 0) { + throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries'); + } + } catch (\RuntimeException $e) { + throw $e; + } catch (\Throwable $e) { + throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage()); + } + } + + /** + * 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. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php index a1e1536..429e052 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php +++ b/source/packages/com_mokosuitebackup/src/Engine/DatabaseDumper.php @@ -27,12 +27,35 @@ class DatabaseDumper private int $tablesCount = 0; + /** @var bool Whether to sanitize user passwords */ + private bool $sanitizePasswords = false; + + /** @var bool Whether to preserve super admin password when sanitizing */ + private bool $preserveSuperAdmin = false; + + /** @var bool Whether to sanitize user emails */ + private bool $sanitizeEmails = false; + + /** @var bool Whether to clear session data */ + private bool $sanitizeSessions = false; + + /** Known invalid bcrypt hash used for sanitized passwords */ + private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000'; + /** - * @param array $excludeTables Table names to exclude (with #__ prefix). - * Supports suffixes: :data-only, :structure-only. - * No suffix = exclude both (backward compatible). + * @param array $excludeTables Table names to exclude (with #__ prefix). + * @param bool $sanitizePasswords Replace user password hashes with invalid value + * @param bool $preserveSuperAdmin Keep super admin password when sanitizing + * @param bool $sanitizeEmails Replace user emails with sanitized placeholders + * @param bool $sanitizeSessions Skip session table data entirely */ - public function __construct(array $excludeTables = []) + public function __construct( + array $excludeTables = [], + bool $sanitizePasswords = false, + bool $preserveSuperAdmin = false, + bool $sanitizeEmails = false, + bool $sanitizeSessions = false + ) { foreach ($excludeTables as $entry) { if (str_ends_with($entry, ':data-only')) { @@ -43,6 +66,16 @@ class DatabaseDumper $this->excludeBoth[] = $entry; } } + + $this->sanitizePasswords = $sanitizePasswords; + $this->preserveSuperAdmin = $preserveSuperAdmin; + $this->sanitizeEmails = $sanitizeEmails; + $this->sanitizeSessions = $sanitizeSessions; + + /* If session sanitization is on, auto-exclude session table data */ + if ($sanitizeSessions) { + $this->excludeDataOnly[] = '#__session'; + } } /** @@ -154,6 +187,7 @@ class DatabaseDumper } foreach ($rows as $row) { + $this->sanitizeRow($row, $abstractName, $db); $values = []; foreach ($row as $value) { @@ -326,6 +360,7 @@ class DatabaseDumper } foreach ($rows as $row) { + $this->sanitizeRow($row, $abstractName, $db); $values = []; foreach ($row as $value) { @@ -351,6 +386,86 @@ class DatabaseDumper return filesize($filePath) ?: 0; } + /** + * Sanitize a row if it belongs to the users table and sanitization is enabled. + * + * Replaces the password column with an invalid hash so the backup + * cannot be used to extract user credentials. + */ + private function sanitizeRow(array &$row, string $abstractTable, object $db): void + { + if ($abstractTable !== '#__users') { + return; + } + + if (!$this->sanitizePasswords && !$this->sanitizeEmails) { + return; + } + + if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) { + $userId = (int) $row['id']; + + /* Preserve super admin emails if preserving super admin */ + if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) { + $row['email'] = 'user' . $userId . '@sanitized.example.com'; + } + } + + if (!$this->sanitizePasswords || !isset($row['password'])) { + return; + } + + if ($this->preserveSuperAdmin && isset($row['id'])) { + if ($this->isSuperAdmin((int) $row['id'], $db)) { + return; + } + } + + $row['password'] = self::SANITIZED_HASH; + } + + /** + * Check if a user ID belongs to the Super Users group (group_id = 8). + */ + private function isSuperAdmin(int $userId, object $db): bool + { + static $superAdminIds = null; + + if ($superAdminIds === null) { + $prefix = $db->getPrefix(); + + try { + $db->setQuery( + $db->getQuery(true) + ->select('DISTINCT ' . $db->quoteName('user_id')) + ->from($db->quoteName($prefix . 'user_usergroup_map')) + ->where($db->quoteName('group_id') . ' = 8') + ); + $superAdminIds = array_map('intval', $db->loadColumn() ?: []); + } catch (\Throwable $e) { + $superAdminIds = []; + } + } + + return in_array($userId, $superAdminIds, true); + } + + /** + * Check if passwords were sanitized (for use by callers to log the action). + */ + public function isPasswordSanitizationEnabled(): bool + { + return $this->sanitizePasswords; + } + + /** + * Get the sentinel hash used for sanitized passwords. + */ + public static function getSanitizedHash(): string + { + return self::SANITIZED_HASH; + } + public function getTablesCount(): int { return $this->tablesCount; diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index 06254ed..f72f513 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -54,6 +54,191 @@ class MokoRestore 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' + + + +SELECTOR; + + /* Insert the selector before the extract step in the HTML */ + $html = str_replace( + '', + $selectorHtml . "\n", + $html + ); + + return $php . $html; + } + /** * 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 { return match ($action) { - 'preflight' => actionPreflight(), - 'extract' => actionExtract($data), - 'testdb' => actionTestDb($data), - 'database' => actionDatabase($data), - 'config' => actionConfig($data), - 'listAdmins' => actionListAdmins($data), - 'resetAdmin' => actionResetAdmin($data), - 'provision' => actionProvision($data), - 'cleanup' => actionCleanup(), - default => ['success' => false, 'message' => 'Unknown action: ' . $action], + 'preflight' => actionPreflight(), + 'extract' => actionExtract($data), + 'scanTables' => actionScanTables(), + 'testdb' => actionTestDb($data), + 'database' => actionDatabase($data), + 'config' => actionConfig($data), + 'listAdmins' => actionListAdmins($data), + 'resetAdmin' => actionResetAdmin($data), + 'postRestore' => actionPostRestore($data), + 'detectSanitized' => detectSanitizedPasswords($data), + 'provision' => actionProvision($data), + 'cleanup' => actionCleanup(), + default => ['success' => false, 'message' => 'Unknown action: ' . $action], }; } @@ -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 { $host = $data['db_host'] ?? 'localhost'; @@ -423,10 +670,27 @@ function actionDatabase(array $data): array // Replace abstract #__ prefix with the user's target prefix $sql = str_replace('#__', $prefix, $sql); + // Decode per-table conflict resolution selections + // Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly + $tableResolutions = []; + + if (!empty($data['table_resolutions'])) { + $decoded = json_decode($data['table_resolutions'], true); + + if (is_array($decoded)) { + // Remap from abstract #__ names to the real prefix + foreach ($decoded as $abstractName => $mode) { + $realName = str_replace('#__', $prefix, $abstractName); + $tableResolutions[$realName] = $mode; + } + } + } + $parts = explode(";\n", $sql); $statements = 0; $errors = 0; $errorList = []; + $skipped = 0; foreach ($parts as $part) { $part = trim($part); @@ -435,6 +699,42 @@ function actionDatabase(array $data): array continue; } + // Determine which table this statement belongs to + $table = getStatementTable($part); + $mode = $tableResolutions[$table] ?? 'replace'; + + // Apply conflict resolution per table + if ($mode === 'skip') { + $skipped++; + continue; + } + + $isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part); + $isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part); + $isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part); + + if ($mode === 'merge') { + // Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO + if ($isDrop || $isCreate) { + $skipped++; + continue; + } + + if ($isInsert) { + $part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part); + } + } elseif ($mode === 'dataonly') { + /* Skip DROP and CREATE; use REPLACE INTO for data (overwrites on duplicate key) */ + if ($isDrop || $isCreate) { + $skipped++; + continue; + } + if ($isInsert) { + $part = preg_replace('/^INSERT\s+INTO/i', 'REPLACE INTO', $part); + } + } + // mode === 'replace' => execute everything as-is (default) + try { $pdo->exec($part); $statements++; @@ -449,11 +749,22 @@ function actionDatabase(array $data): array $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + $msg = "Executed {$statements} statements"; + + if ($skipped > 0) { + $msg .= " ({$skipped} skipped)"; + } + + if ($errors > 0) { + $msg .= " ({$errors} warnings)"; + } + return [ 'success' => ($statements > 0 || $errors === 0), - 'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''), + 'message' => $msg, 'statements' => $statements, 'errors' => $errors, + 'skipped' => $skipped, 'errorList' => $errorList, ]; } @@ -804,6 +1115,128 @@ function actionResetAdmin(array $data): array return ['success' => true, 'message' => 'Admin password updated successfully']; } +function actionPostRestore(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = getValidatedPrefix($data); + $tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; + $results = []; + + foreach ($tasks as $task) { + try { + switch ($task) { + case 'reset_passwords': + /* Set all user passwords to a random temporary hash, block non-admin users */ + $tempPassword = bin2hex(random_bytes(8)); /* 16-char random hex */ + // clear activation tokens, and force password reset on next login. + $tempHash = password_hash($tempPassword, PASSWORD_DEFAULT); + $stmt = $pdo->prepare( + "UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1" + ); + $stmt->execute([$tempHash]); + $affected = $stmt->rowCount(); + $results[] = "All {$affected} user password(s) reset to temporary password ({$tempPassword}) with forced reset"; + break; + + case 'reset_hits': + $pdo->exec("UPDATE {$prefix}content SET hits = 0"); + $results[] = 'Content hits reset to 0'; + break; + + case 'clear_versions': + try { + $pdo->exec("TRUNCATE TABLE {$prefix}history"); + $results[] = 'Content version history cleared'; + } catch (PDOException $e) { + $results[] = 'Version history: table not found (skipped)'; + } + break; + + case 'clear_sessions': + $pdo->exec("TRUNCATE TABLE {$prefix}session"); + $results[] = 'Sessions cleared'; + break; + + case 'clear_cache': + // Clear Joomla cache tables + foreach (['cache', 'cache_extension'] as $tbl) { + try { + $pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}"); + } catch (PDOException $e) { + // Table may not exist + } + } + + // Delete files in cache/ directory + $cacheDir = RESTORE_DIR . '/cache'; + $cacheCount = 0; + + if (is_dir($cacheDir)) { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $item) { + if ($item->isFile()) { + @unlink($item->getPathname()); + $cacheCount++; + } elseif ($item->isDir()) { + @rmdir($item->getPathname()); + } + } + } + + // Also clear administrator/cache/ + $adminCacheDir = RESTORE_DIR . '/administrator/cache'; + + if (is_dir($adminCacheDir)) { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $item) { + if ($item->isFile()) { + @unlink($item->getPathname()); + $cacheCount++; + } elseif ($item->isDir()) { + @rmdir($item->getPathname()); + } + } + } + + $results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed"; + break; + + default: + $results[] = "Unknown task: {$task}"; + } + } catch (Throwable $e) { + $results[] = "Error ({$task}): " . $e->getMessage(); + } + } + + return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed']; +} + +/** + * Detect whether the database contains sanitized sentinel password hashes. + * Returns true if any user has the MokoSuiteBackup sanitized placeholder hash. + */ +function detectSanitizedPasswords(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = getValidatedPrefix($data); + $sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000'; + + $stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?"); + $stmt->execute([$sentinel]); + $count = (int) $stmt->fetchColumn(); + + return ['success' => true, 'detected' => $count > 0, 'count' => $count]; +} + function actionProvision(array $data): array { $pdo = getDbConnection($data); @@ -1048,11 +1481,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1Checks
2Extract
-
3Database
-
4Configuration
-
5Admin
-
6Provisioning
-
7Complete
+
3Tables
+
4Database
+
5Configuration
+
6Admin
+
7Post-Restore
+
8Provisioning
+
9Complete
@@ -1107,8 +1542,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N - +
+

Table Conflict Resolution

+

Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.

+
+ + + + +
+
+ Modes: + Replace = drop + recreate + insert (default). + Skip = ignore entirely. + Merge = keep existing table, INSERT IGNORE new rows. + Data Only = keep schema, INSERT data as-is (assumes matching structure). +
+
+
Scanning tables...
+
+ +
+
+ + +
+
+ + +

Database Configuration

Enter the database credentials for this server. The SQL dump will be imported.

@@ -1127,13 +1590,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- -
+ +

Site Configuration

Configure your site settings. Credentials were removed from the backup for security — enter the correct values for this server.

@@ -1176,13 +1639,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- -
+ +

Super Admin Password

Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.

@@ -1195,16 +1658,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- +
- -
+ +
+

Post-Restore Actions

+

Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.

+ +
    +
  • Reset all user passwordsSet to random temporary password and force reset on next login
  • +
  • Reset content hitsSet all article hit counters to 0
  • +
  • Clear version historyTruncate the content version history table
  • +
  • Clear sessionsRemove all active user sessions
  • +
  • Clear cacheTruncate cache tables and delete cache files
  • +
+
+
+ +
+ + +
+
+
+ + +

Client Provisioning

Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.

    @@ -1219,16 +1706,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- +
- -
+ +

Installation Complete

Your Joomla site has been restored and configured.

@@ -1299,7 +1786,9 @@ function goStep(n) { else if (sn < n) s.classList.add('done'); }); - if (n === 5) loadAdmins(); + if (n === 3) scanTables(); + if (n === 6) loadAdmins(); + if (n === 7) checkSanitizedPasswords(); } function setStatus(id, msg, type) { @@ -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 = '
No tables found in database.sql (or file not present). You can skip this step.
'; + setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error'); + log(r.message || 'No tables found'); + return; + } + + tableList = r.tables; + log('Found ' + r.count + ' tables'); + setStatus('tableScanStatus', 'Found ' + r.count + ' tables', 'success'); + + renderTableList(); +} + +function renderTableList() { + const container = document.getElementById('tableResolutionList'); + container.innerHTML = ''; + + var resolutions = {}; + + tableList.forEach(function(name) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;'; + + var label = document.createElement('span'); + label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;'; + label.textContent = name; + + var sel = document.createElement('select'); + sel.dataset.table = name; + sel.className = 'table-mode-select'; + sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;'; + + var modes = [ + ['replace', 'Replace'], + ['skip', 'Skip'], + ['merge', 'Merge'], + ['dataonly', 'Data Only'] + ]; + + modes.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m[0]; + opt.textContent = m[1]; + sel.appendChild(opt); + }); + + sel.addEventListener('change', updateTableResolutions); + + row.appendChild(label); + row.appendChild(sel); + container.appendChild(row); + + resolutions[name] = 'replace'; + }); + + document.getElementById('tableResolutions').value = JSON.stringify(resolutions); +} + +function updateTableResolutions() { + var resolutions = {}; + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + resolutions[sel.dataset.table] = sel.value; + }); + document.getElementById('tableResolutions').value = JSON.stringify(resolutions); +} + +function setAllTableMode(mode) { + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + sel.value = mode; + }); + updateTableResolutions(); + log('Set all tables to: ' + mode); +} + +function presetExceptUsers() { + var userTables = ['#__users', '#__user_usergroup_map', '#__user_profiles']; + + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + var tableName = sel.dataset.table; + + if (userTables.indexOf(tableName) !== -1) { + sel.value = 'skip'; + } else { + sel.value = 'replace'; + } + }); + + updateTableResolutions(); + log('Preset: Replace all except user tables (skipped)'); +} + +// Step 4 function getDbParams() { return { db_host: document.getElementById('dbHost').value, @@ -1462,7 +2055,12 @@ async function runDatabase() { log('Importing database...'); dbConfig = getDbParams(); - const r = await post('database', dbConfig); + // Include table conflict resolution selections + var tableRes = document.getElementById('tableResolutions'); + var dbParams = Object.assign({}, dbConfig, { + table_resolutions: tableRes ? tableRes.value : '{}' + }); + const r = await post('database', dbParams); document.getElementById('dbProgress').style.width = '100%'; setBtnLoading(btn, false); @@ -1470,17 +2068,20 @@ async function runDatabase() { if (r.success) { setStatus('dbStatus', r.message, 'success'); log(r.message); + if (r.skipped && r.skipped > 0) { + log(' Skipped ' + r.skipped + ' statements due to conflict resolution'); + } if (r.errorList && r.errorList.length > 0) { r.errorList.forEach(function(e) { log(' Warning: ' + e); }); } - setTimeout(function() { goStep(4); }, 500); + setTimeout(function() { goStep(5); }, 500); } else { setStatus('dbStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 4 +// Step 5 async function runConfig() { const btn = document.getElementById('btnConfig'); setBtnLoading(btn, true); @@ -1501,14 +2102,14 @@ async function runConfig() { if (r.success) { setStatus('configStatus', r.message, 'success'); log(r.message); - setTimeout(function() { goStep(5); }, 500); + setTimeout(function() { goStep(6); }, 500); } else { setStatus('configStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 5 +// Step 6 async function loadAdmins() { const sel = document.getElementById('adminSelect'); while (sel.firstChild) sel.removeChild(sel.firstChild); @@ -1553,20 +2154,65 @@ async function runResetAdmin() { if (r.success) { setStatus('adminStatus', r.message, 'success'); log(r.message); - setTimeout(function() { goStep(6); }, 500); + setTimeout(function() { goStep(7); }, 500); } else { setStatus('adminStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 6 +// Step 7: Post-Restore +async function checkSanitizedPasswords() { + log('Checking for sanitized password hashes...'); + + try { + const r = await post('detectSanitized', dbConfig); + + if (r.success && r.detected) { + document.getElementById('postRestoreSanitizedWarn').style.display = ''; + document.getElementById('prResetPasswords').checked = true; + log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords'); + } else { + document.getElementById('postRestoreSanitizedWarn').style.display = 'none'; + log('No sanitized passwords detected'); + } + } catch (e) { + log('Could not check for sanitized passwords: ' + e.message); + } +} + +async function runPostRestore() { + const btn = document.getElementById('btnPostRestore'); + const tasks = []; + document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); }); + + if (tasks.length === 0) { goStep(8); return; } + + setBtnLoading(btn, true); + log('Running ' + tasks.length + ' post-restore task(s)...'); + + const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) }); + const r = await post('postRestore', params); + + setBtnLoading(btn, false); + + if (r.success) { + setStatus('postRestoreStatus', r.message, 'success'); + r.results.forEach(function(msg) { log(' ' + msg); }); + setTimeout(function() { goStep(8); }, 500); + } else { + setStatus('postRestoreStatus', r.message, 'error'); + log('FAILED: ' + r.message); + } +} + +// Step 8 async function runProvision() { const btn = document.getElementById('btnProvision'); const tasks = []; document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); }); - if (tasks.length === 0) { goStep(7); return; } + if (tasks.length === 0) { goStep(9); return; } setBtnLoading(btn, true); log('Running ' + tasks.length + ' provisioning tasks...'); @@ -1579,14 +2225,14 @@ async function runProvision() { if (r.success) { setStatus('provisionStatus', r.message, 'success'); r.results.forEach(function(msg) { log(' ' + msg); }); - setTimeout(function() { goStep(7); }, 500); + setTimeout(function() { goStep(9); }, 500); } else { setStatus('provisionStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 7 +// Step 9 async function runCleanup() { log('Cleaning up restore files...'); const r = await post('cleanup'); diff --git a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php index a5631fd..a214c7a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php +++ b/source/packages/com_mokosuitebackup/src/Engine/NotificationSender.php @@ -236,6 +236,297 @@ class NotificationSender } } + /** + * Send a restore/snapshot notification via email and ntfy. + * + * @param object $profile Profile object with notification settings + * @param string $type One of: site_restore, snapshot_create, snapshot_restore + * @param array $details Context: record_id, content_types, row_count, mode, user, etc. + * @param string $log Operation log text + * + * @return bool True if at least one notification was sent + */ + public static function sendRestoreNotification(object $profile, string $type, array $details, string $log = ''): bool + { + $emailSent = self::sendRestoreEmail($profile, $type, $details, $log); + $ntfySent = self::sendRestoreNtfy($profile, $type, $details); + + return $emailSent || $ntfySent; + } + + /** + * Load the default profile (ID 1) for notification settings. + * + * @return object|null Profile object or null if not found + */ + public static function getDefaultProfile(): ?object + { + try { + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = 1'); + $db->setQuery($query); + + return $db->loadObject() ?: null; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Cannot load default profile: ' . $e->getMessage()); + + return null; + } + } + + /** + * Build subject and body for a restore/snapshot notification email. + */ + private static function buildRestoreMessage(string $type, array $details, string $siteName, string $siteUrl): array + { + $user = $details['user'] ?? 'Unknown'; + + switch ($type) { + case 'site_restore': + $subject = "[MokoSuiteBackup] RESTORE: Site restored — {$siteName}"; + $options = []; + + if (!empty($details['restore_files'])) { + $options[] = 'Files'; + } + + if (!empty($details['restore_db'])) { + $options[] = 'Database'; + } + + if (!empty($details['preserve_config'])) { + $options[] = 'Config preserved'; + } + + $body = "MokoSuiteBackup — Site Restore Notification\n" + . "=============================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Full site restore\n" + . "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Options: " . (empty($options) ? 'N/A' : implode(', ', $options)) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_create': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] SNAPSHOT: Content snapshot created — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Created\n" + . "===================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot created\n" + . "Content types: {$typesStr}\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + case 'snapshot_restore': + $types = $details['content_types'] ?? []; + $typesStr = !empty($types) ? implode(', ', $types) : 'N/A'; + + $subject = "[MokoSuiteBackup] RESTORE: Snapshot restored — {$siteName}"; + $body = "MokoSuiteBackup — Snapshot Restore Notification\n" + . "================================================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Action: Snapshot restore\n" + . "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Content types: {$typesStr}\n" + . "Rows restored: " . ($details['row_count'] ?? 0) . "\n" + . "Triggered by: {$user}\n"; + break; + + default: + $subject = "[MokoSuiteBackup] NOTIFICATION: {$type} — {$siteName}"; + $body = "MokoSuiteBackup Notification\n" + . "============================\n\n" + . "Site: {$siteName}\n" + . "URL: {$siteUrl}\n" + . "Type: {$type}\n" + . "Details: " . json_encode($details) . "\n"; + break; + } + + $body .= "\n--\n" + . "MokoSuiteBackup — https://mokoconsulting.tech\n"; + + return ['subject' => $subject, 'body' => $body]; + } + + /** + * Send a restore/snapshot notification email. + */ + private static function sendRestoreEmail(object $profile, string $type, array $details, string $log = ''): bool + { + $notifyEmail = trim($profile->notify_email ?? ''); + $notifyUserGroups = $profile->notify_user_groups ?? ''; + + $groupEmails = self::resolveUserGroupEmails($notifyUserGroups); + + if (empty($notifyEmail) && empty($groupEmails)) { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + try { + $mailer = Factory::getMailer(); + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + $siteUrl = Uri::root(); + + $recipients = array_map('trim', explode(',', $notifyEmail)); + $recipients = array_merge($recipients, $groupEmails); + $recipients = array_unique(array_filter($recipients, fn($e) => filter_var($e, FILTER_VALIDATE_EMAIL))); + + if (empty($recipients)) { + return false; + } + + foreach ($recipients as $recipient) { + $mailer->addRecipient($recipient); + } + + $message = self::buildRestoreMessage($type, $details, $siteName, $siteUrl); + $mailer->setSubject($message['subject']); + + $body = $message['body']; + + // Append log excerpt if provided (last 30 lines) + if (!empty($log)) { + $logLines = explode("\n", $log); + $excerpt = array_slice($logLines, -30); + $body .= "\n--- Log (last 30 lines) ---\n" + . implode("\n", $excerpt) . "\n"; + } + + $mailer->setBody($body); + $mailer->isHtml(false); + + return $mailer->Send(); + } catch (\Throwable $e) { + error_log('MokoSuiteBackup restore notification error: ' . $e->getMessage()); + + return false; + } + } + + /** + * Send a restore/snapshot push notification via ntfy. + */ + private static function sendRestoreNtfy(object $profile, string $type, array $details): bool + { + $topic = trim($profile->ntfy_topic ?? ''); + $server = trim($profile->ntfy_server ?? 'https://ntfy.sh'); + $token = trim($profile->ntfy_token ?? ''); + + if ($topic === '') { + return false; + } + + // Restore notifications are always "success" events — use notify_on_success preference + if (empty($profile->notify_on_success)) { + return false; + } + + if (!function_exists('curl_init')) { + error_log('MokoSuiteBackup: ntfy notifications require ext-curl'); + + return false; + } + + try { + $config = Factory::getApplication()->getConfig(); + $siteName = $config->get('sitename', 'Joomla Site'); + + switch ($type) { + case 'site_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $title = "{$emoji} Site Restored: {$siteName}"; + $body = "Record ID: " . ($details['record_id'] ?? 'N/A') . "\n" + . "Triggered by: " . ($details['user'] ?? 'Unknown'); + break; + + case 'snapshot_create': + $emoji = "\xF0\x9F\x93\xB8"; // 📸 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Created: {$siteName}"; + $body = "Types: " . implode(', ', $types) . "\n" + . "Articles: " . ($details['articles_count'] ?? 0) . "\n" + . "Categories: " . ($details['categories_count'] ?? 0) . "\n" + . "Modules: " . ($details['modules_count'] ?? 0); + break; + + case 'snapshot_restore': + $emoji = "\xF0\x9F\x94\x84"; // 🔄 + $types = $details['content_types'] ?? []; + $title = "{$emoji} Snapshot Restored: {$siteName}"; + $body = "Mode: " . ($details['mode'] ?? 'N/A') . "\n" + . "Types: " . implode(', ', $types) . "\n" + . "Rows: " . ($details['row_count'] ?? 0); + break; + + default: + $title = "MokoSuiteBackup: {$type} — {$siteName}"; + $body = json_encode($details); + break; + } + + $url = rtrim($server, '/') . '/' . rawurlencode($topic); + + $headers = [ + 'Title: ' . $title, + 'Priority: 3', + 'Tags: arrows_counterclockwise', + ]; + + if ($token !== '') { + $headers[] = 'Authorization: Bearer ' . $token; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error !== '') { + error_log('MokoSuiteBackup: ntfy error: ' . $error); + return false; + } + + if ($httpCode < 200 || $httpCode >= 300) { + error_log('MokoSuiteBackup: ntfy returned HTTP ' . $httpCode . ': ' . substr((string) $response, 0, 200)); + return false; + } + + return true; + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: ntfy restore notification error: ' . $e->getMessage()); + return false; + } + } + /** * Resolve user group IDs to email addresses of group members. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php b/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php index b2eb727..2d6b548 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php @@ -7,7 +7,7 @@ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * - * Resolves placeholders like [host], [date], [profile_name] in backup + * Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup * directory paths and archive filename formats. */ @@ -24,21 +24,21 @@ class PlaceholderResolver * Supported placeholders and their descriptions (for documentation). */ public const PLACEHOLDERS = [ - '[host]' => 'Server hostname', - '[date]' => 'Date as Ymd (e.g. 20260604)', - '[time]' => 'Time as His (e.g. 143025)', - '[datetime]' => 'Date and time as Ymd_His', - '[year]' => 'Four-digit year', - '[month]' => 'Two-digit month', - '[day]' => 'Two-digit day', - '[hour]' => 'Two-digit hour (24h)', - '[minute]' => 'Two-digit minute', - '[second]' => 'Two-digit second', - '[profile_id]' => 'Backup profile ID', - '[profile_name]' => 'Profile title (sanitized)', - '[site_name]' => 'Joomla site name (sanitized)', - '[type]' => 'Backup type (full, database, files, differential)', - '[random]' => 'Random 6-character hex string', + '[HOST]' => 'Server hostname', + '[DATE]' => 'Date as Ymd (e.g. 20260604)', + '[TIME]' => 'Time as His (e.g. 143025)', + '[DATETIME]' => 'Date and time as Ymd_His', + '[YEAR]' => 'Four-digit year', + '[MONTH]' => 'Two-digit month', + '[DAY]' => 'Two-digit day', + '[HOUR]' => 'Two-digit hour (24h)', + '[MINUTE]' => 'Two-digit minute', + '[SECOND]' => 'Two-digit second', + '[PROFILE_ID]' => 'Backup profile ID', + '[PROFILE_NAME]' => 'Profile title (sanitized)', + '[SITE_NAME]' => 'Joomla site name (sanitized)', + '[TYPE]' => 'Backup type (full, database, files, differential)', + '[RANDOM]' => 'Random 6-character hex string', '[DEFAULT_DIR]' => 'Default backup directory', '[HOME]' => 'Home directory of the PHP process owner', ]; @@ -51,7 +51,32 @@ class PlaceholderResolver public function __construct(object $profile) { $now = new \DateTimeImmutable('now'); - $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n')); + + /* 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 = ''; @@ -62,21 +87,21 @@ class PlaceholderResolver } $this->replacements = [ - '[host]' => $hostname, - '[date]' => $now->format('Ymd'), - '[time]' => $now->format('His'), - '[datetime]' => $now->format('Ymd_His'), - '[year]' => $now->format('Y'), - '[month]' => $now->format('m'), - '[day]' => $now->format('d'), - '[hour]' => $now->format('H'), - '[minute]' => $now->format('i'), - '[second]' => $now->format('s'), - '[profile_id]' => (string) ($profile->id ?? '0'), - '[profile_name]' => $this->sanitize($profile->title ?? 'default'), - '[site_name]' => $this->sanitize($siteName ?: 'joomla'), - '[type]' => $profile->backup_type ?? 'full', - '[random]' => bin2hex(random_bytes(3)), + '[HOST]' => $hostname, + '[DATE]' => $now->format('Ymd'), + '[TIME]' => $now->format('His'), + '[DATETIME]' => $now->format('Ymd_His'), + '[YEAR]' => $now->format('Y'), + '[MONTH]' => $now->format('m'), + '[DAY]' => $now->format('d'), + '[HOUR]' => $now->format('H'), + '[MINUTE]' => $now->format('i'), + '[SECOND]' => $now->format('s'), + '[PROFILE_ID]' => (string) ($profile->id ?? '0'), + '[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'), + '[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'), + '[TYPE]' => $profile->backup_type ?? 'full', + '[RANDOM]' => bin2hex(random_bytes(3)), '[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(), '[HOME]' => BackupDirectory::getHomeDirectory(), ]; @@ -103,7 +128,7 @@ class PlaceholderResolver */ public function getHostname(): string { - return $this->replacements['[host]']; + return $this->replacements['[HOST]']; } /** @@ -111,7 +136,7 @@ class PlaceholderResolver */ public function getTag(): string { - return $this->replacements['[datetime]']; + return $this->replacements['[DATETIME]']; } /** diff --git a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php index 74faef4..ac62cd3 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php @@ -278,6 +278,21 @@ class PreflightCheck 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': if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) { $this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail'; diff --git a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php index 7ffd2b6..55202d0 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RestoreEngine.php @@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class RestoreEngine { @@ -146,6 +147,29 @@ class RestoreEngine $this->log('Restore complete'); + // Send restore notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userId = Factory::getApplication()->getIdentity()->id ?? 0; + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + + NotificationSender::sendRestoreNotification($profile, 'site_restore', [ + 'record_id' => $recordId, + 'restore_files' => $restoreFiles, + 'restore_db' => $restoreDb, + 'preserve_config' => $preserveConfig, + 'user' => $userName . ' (ID: ' . $userId . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage()); + } + + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRestore(true, $recordId); + return [ 'success' => true, 'message' => 'Restore complete from: ' . basename($archivePath), @@ -165,6 +189,9 @@ class RestoreEngine $this->recursiveDelete($this->stagingDir); } + // Dispatch event for actionlog and other listeners + $this->dispatchAfterRestore(false, $recordId); + return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), @@ -265,6 +292,26 @@ class RestoreEngine @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 { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php b/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php new file mode 100644 index 0000000..ae19ba1 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SevenZipArchiver.php @@ -0,0 +1,260 @@ + + * @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); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php new file mode 100644 index 0000000..980ea4a --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php @@ -0,0 +1,255 @@ + + * @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); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php index 4ea16a3..b61211c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php @@ -17,6 +17,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; +use Joomla\Event\Event; class SnapshotEngine { @@ -41,6 +42,10 @@ class SnapshotEngine private const ARTICLE_RELATED = [ '#__workflow_associations', '#__contentitem_tag_map', + '#__tags', + '#__fields', + '#__fields_values', + '#__fields_categories', ]; /** @@ -107,6 +112,32 @@ class SnapshotEngine $rows = $this->dumpTagMap($db, $prefix); $data['tables']['#__contentitem_tag_map'] = $rows; $this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows'); + + // Tags — dump all (shared, small table) + $rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__tags'), '#__tags', 'articles'); + $data['tables']['#__tags'] = $rows; + $this->log(' #__tags: ' . count($rows) . ' rows'); + + // Custom fields — only com_content.article context + $rows = $this->dumpFilteredTable( + $db, + str_replace('#__', $prefix, '#__fields'), + '#__fields', + 'context', + 'com_content.article' + ); + $data['tables']['#__fields'] = $rows; + $this->log(' #__fields: ' . count($rows) . ' rows'); + + // Field values — only for com_content.article fields (table is shared across extensions) + $rows = $this->dumpFieldValues($db, $prefix); + $data['tables']['#__fields_values'] = $rows; + $this->log(' #__fields_values: ' . count($rows) . ' rows'); + + // Field-category mappings — only for com_content.article fields + $rows = $this->dumpFieldCategories($db, $prefix); + $data['tables']['#__fields_categories'] = $rows; + $this->log(' #__fields_categories: ' . count($rows) . ' rows'); } // Count items @@ -164,6 +195,29 @@ class SnapshotEngine $this->log('Snapshot record created: ID ' . $record->id); + // Send snapshot creation notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_create', [ + 'content_types' => array_values($validTypes), + 'articles_count' => $counts['articles'], + 'categories_count' => $counts['categories'], + 'modules_count' => $counts['modules'], + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + 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 [ 'success' => true, 'message' => sprintf( @@ -177,6 +231,9 @@ class SnapshotEngine } catch (\Exception $e) { $this->log('FATAL: ' . $e->getMessage()); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshot(false, 0, $contentTypes); + return [ 'success' => false, 'message' => 'Snapshot failed: ' . $e->getMessage(), @@ -231,6 +288,73 @@ class SnapshotEngine return $db->loadAssocList() ?: []; } + /** + * Dump field-category mappings for com_content.article fields. + * + * Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article') + */ + /** + * Dump field values only for com_content.article fields. + */ + private function dumpFieldValues(object $db, string $prefix): array + { + $fvTable = $prefix . 'fields_values'; + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($fvTable)) + ->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + $db->setQuery($query); + + return $db->loadAssocList() ?: []; + } + + private function dumpFieldCategories(object $db, string $prefix): array + { + $fcTable = $prefix . 'fields_categories'; + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName($fcTable)) + ->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + $db->setQuery($query); + + 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 { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 6bb514f..0bf3c30 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\CMS\Factory; +use Joomla\Event\Event; class SnapshotRestoreEngine { @@ -33,6 +34,10 @@ class SnapshotRestoreEngine '#__contentitem_tag_map' => null, // composite key, handled specially '#__modules' => 'id', '#__modules_menu' => null, // composite key, handled specially + '#__tags' => 'id', + '#__fields' => 'id', + '#__fields_values' => null, // composite key, handled specially + '#__fields_categories' => null, // composite key, handled specially ]; /** @@ -147,6 +152,28 @@ class SnapshotRestoreEngine $this->log('Restore complete: ' . $totalRows . ' total rows'); + // Send snapshot restore notification + try { + $profile = NotificationSender::getDefaultProfile(); + + if ($profile) { + $userName = Factory::getApplication()->getIdentity()->username ?? 'Unknown'; + $userIdVal = Factory::getApplication()->getIdentity()->id ?? 0; + + NotificationSender::sendRestoreNotification($profile, 'snapshot_restore', [ + 'mode' => $mode, + 'content_types' => $restoreTypes, + 'row_count' => $totalRows, + 'user' => $userName . ' (ID: ' . $userIdVal . ')', + ], implode("\n", $this->log)); + } + } catch (\Throwable $e) { + error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage()); + } + + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode); + return [ 'success' => true, 'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)), @@ -162,6 +189,9 @@ class SnapshotRestoreEngine $this->log('FATAL: ' . $e->getMessage()); + // Dispatch event for actionlog and other listeners + $this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode); + return [ 'success' => false, 'message' => 'Restore failed: ' . $e->getMessage(), @@ -282,6 +312,48 @@ class SnapshotRestoreEngine $query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')'); break; + case '#__tags': + // Only delete tags that exist in the snapshot — never wipe all tags + $ids = array_filter(array_column($rows, 'id')); + + if (empty($ids)) { + return; + } + + $ids = array_map('intval', $ids); + $query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')'); + break; + + case '#__fields': + // Only delete custom fields scoped to com_content.article + $query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + break; + + case '#__fields_values': + // Only delete field values for com_content.article fields + $prefix = $db->getPrefix(); + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + break; + + case '#__fields_categories': + // Delete field-category mappings for com_content.article fields only + $prefix = $db->getPrefix(); + $fTable = $prefix . 'fields'; + + $subQuery = $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName($fTable)) + ->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article')); + + $query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')'); + break; + // #__content and #__content_frontpage are fully owned by com_content default: break; @@ -303,6 +375,10 @@ class SnapshotRestoreEngine $tables[] = '#__content_frontpage'; $tables[] = '#__workflow_associations'; $tables[] = '#__contentitem_tag_map'; + $tables[] = '#__tags'; + $tables[] = '#__fields'; + $tables[] = '#__fields_values'; + $tables[] = '#__fields_categories'; } if (in_array('categories', $types)) { @@ -317,6 +393,208 @@ class SnapshotRestoreEngine 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 { $this->log[] = '[' . date('H:i:s') . '] ' . $message; diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 6ba97ea..c808882 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -73,6 +73,10 @@ class SteppedBackupEngine $session->includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); $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 $resolver = new PlaceholderResolver($profile); $backupDir = BackupDirectory::resolve($resolver->resolve($session->backupDir)); @@ -81,9 +85,21 @@ class SteppedBackupEngine return ['error' => true, 'message' => 'Cannot create backup directory: ' . $backupDir]; } - $now = date('Y-m-d H:i:s'); - $tag = $resolver->getTag(); - $nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]'; + $now = date('Y-m-d H:i:s'); + $tag = $resolver->getTag(); + $archiveFormat = $profile->archive_format ?? 'zip'; + $nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]'; + + // The stepped engine uses ZipArchive batch-by-batch, so only ZIP is + // supported. For 7z / tar.gz the non-stepped BackupEngine must be used. + if ($archiveFormat !== 'zip') { + return [ + 'error' => true, + 'message' => 'The stepped backup engine only supports ZIP format. ' + . 'Please use the CLI or API backup for ' . $archiveFormat . ' archives.', + ]; + } + $archiveName = $resolver->resolve($nameFormat) . '.zip'; $session->archivePath = $backupDir . '/' . $archiveName; @@ -135,13 +151,22 @@ class SteppedBackupEngine } $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->currentStep = 1; $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; $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 foreach ($preflightResult['warnings'] as $warning) { $session->log('PREFLIGHT WARNING: ' . $warning); @@ -347,6 +372,11 @@ class SteppedBackupEngine $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; + // Verify archive integrity + $session->log('Verifying archive integrity...'); + $this->verifyArchive($session->archivePath, $session->backupType); + $session->log('Archive integrity verified'); + // MokoRestore wrapper if ($session->includeMokoRestore) { $session->log('Wrapping with MokoRestore script...'); @@ -374,7 +404,17 @@ class SteppedBackupEngine $db->updateObject('#__mokosuitebackup_records', $update, 'id'); $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->log('Archive finalized: ' . $sizeHuman); @@ -385,62 +425,187 @@ class SteppedBackupEngine /** * 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 { $db = Factory::getDbo(); - - // 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), - '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); - $remoteFilename = ''; + $uploadFailed = false; - if ($result['success']) { - $remoteFilename = $result['remote_path'] ?? $session->archiveName; - $session->log('Remote upload complete: ' . $result['message']); + if (!empty($session->remoteDestinations)) { + // ── Multi-remote path ────────────────────────────────── + $index = $session->remoteIndex; - if (!$session->remoteKeepLocal && is_file($session->archivePath)) { - @unlink($session->archivePath); - $session->log('Local copy removed'); + if ($index >= count($session->remoteDestinations)) { + // All remotes processed — move to complete + $session->phase = 'complete'; + $session->statusMessage = 'All remote uploads finished'; + $this->completeRecord($session); + + return; + } + + $remote = (object) $session->remoteDestinations[$index]; + + try { + $title = $remote->title ?? ('Remote #' . ($index + 1)); + $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']); + } + } catch (\Throwable $e) { + $uploadFailed = true; + $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 { - $session->log('WARNING: Remote upload failed: ' . $result['message']); + // ── 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.'); + } + + // 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); + } + } + + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); } - // Update record with remote filename - $update = (object) [ - 'id' => $session->recordId, - 'remote_filename' => $remoteFilename, - 'filesexist' => is_file($session->archivePath) ? 1 : 0, - ]; + $zip = new \ZipArchive(); - $db->updateObject('#__mokosuitebackup_records', $update, 'id'); + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } - $session->currentStep++; - $session->phase = 'complete'; - $session->statusMessage = 'Backup complete'; - $this->completeRecord($session); + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); } /** * Mark the backup record as complete. */ - private function completeRecord(SteppedSession $session): void + private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void { $db = Factory::getDbo(); $logContent = implode("\n", $session->log); @@ -490,6 +655,11 @@ class SteppedBackupEngine ]; NotificationSender::send($profile, $record, true, $logContent); + + // If remote upload failed, also send a failure notification for the upload + if ($uploadFailed) { + NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent); + } } } catch (\Throwable $e) { error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage()); @@ -650,4 +820,58 @@ class SteppedBackupEngine 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), + }; + } + } diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php new file mode 100644 index 0000000..cf1b9ef --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedRestoreEngine.php @@ -0,0 +1,753 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * AJAX step-based restore engine for shared hosting. + * + * Each call to runStep() performs one unit of work within the PHP time + * limit, saves state, and returns. The browser JS fires the next step. + * + * Phases: extract -> files -> database -> config -> cleanup -> complete + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; + +class SteppedRestoreEngine +{ + /** + * Number of files to copy per step during the files phase. + */ + private const FILE_BATCH_SIZE = 200; + + /** + * Number of SQL statements to execute per step during the database phase. + */ + private const SQL_BATCH_SIZE = 500; + + /** + * Initialize a new stepped restore session. + * + * @param int $recordId Backup record ID to restore from + * @param bool $restoreFiles Whether to restore files + * @param bool $restoreDb Whether to restore the database + * @param bool $preserveConfig Keep current configuration.php + * @param string $password Decryption password (for encrypted archives) + * + * @return array{session_id: string, phase: string, progress: int, message: string} + */ + public function init(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true, string $password = ''): array + { + if (!extension_loaded('zip')) { + return ['error' => true, 'message' => 'PHP ext-zip is required for restore operations']; + } + + $db = Factory::getDbo(); + + // Load backup record + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('id') . ' = ' . $recordId); + $db->setQuery($query); + $record = $db->loadObject(); + + if (!$record) { + return ['error' => true, 'message' => 'Backup record not found: ' . $recordId]; + } + + if ($record->status !== 'complete') { + return ['error' => true, 'message' => 'Cannot restore from incomplete backup (status: ' . $record->status . ')']; + } + + $archivePath = $record->absolute_path; + + if (!is_file($archivePath) || !is_readable($archivePath)) { + return ['error' => true, 'message' => 'Backup archive not found: ' . $archivePath]; + } + + // Create session + $session = SteppedSession::create(); + $session->recordId = $recordId; + $session->archivePath = $archivePath; + $session->archiveName = basename($archivePath); + $session->description = 'Restore from: ' . ($record->description ?: basename($archivePath)); + + // Store restore-specific settings as dynamic properties via the session's + // generic save/load (SteppedSession serialises all public properties). + // We repurpose some existing fields and add restore-specific ones to the + // session data stored on disk. + $session->phase = 'extract'; + + // Build staging directory path + $safeTag = preg_replace('/[^a-zA-Z0-9_-]/', '', $record->tag ?: 'restore'); + $stagingDir = JPATH_ROOT . '/tmp/mokosuitebackup-restore-' . $safeTag . '-' . substr($session->sessionId, 3); + + // Estimate total steps + $totalSteps = 1; // extract step + + if ($restoreFiles) { + $totalSteps += 1; // at least one files step (will adjust after extraction) + } + + if ($restoreDb) { + $totalSteps += 1; // at least one database step (will adjust after extraction) + } + + $totalSteps += 1; // config step + $totalSteps += 1; // cleanup step + + $session->totalSteps = $totalSteps; + $session->currentStep = 0; + $session->statusMessage = 'Initializing restore...'; + + // Store restore-specific data in session log metadata + // We'll use a JSON file alongside the session for restore state + $restoreState = [ + 'staging_dir' => $stagingDir, + 'restore_files' => $restoreFiles, + 'restore_db' => $restoreDb, + 'preserve_config' => $preserveConfig, + 'password' => $password, + 'config_backup' => '', + 'file_list' => [], + 'file_index' => 0, + 'sql_file' => '', + 'sql_offset' => 0, + 'sql_done' => false, + 'sql_executed' => 0, + ]; + + $this->saveRestoreState($session->sessionId, $restoreState); + + $session->log('Restore initialized for record #' . $recordId . ': ' . $record->description); + $session->log('Archive: ' . $archivePath); + $session->log('Options: files=' . ($restoreFiles ? 'yes' : 'no') + . ', database=' . ($restoreDb ? 'yes' : 'no') + . ', preserve_config=' . ($preserveConfig ? 'yes' : 'no')); + $session->save(); + + return [ + 'session_id' => $session->sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + ]; + } + + /** + * Run the next step of a restore session. + * + * @return array{session_id: string, phase: string, progress: int, message: string, done?: bool} + */ + public function runStep(string $sessionId): array + { + $session = SteppedSession::load($sessionId); + + if (!$session) { + return ['error' => true, 'message' => 'Session not found: ' . $sessionId]; + } + + $restoreState = $this->loadRestoreState($sessionId); + + if (!$restoreState) { + return ['error' => true, 'message' => 'Restore state not found for session: ' . $sessionId]; + } + + try { + switch ($session->phase) { + case 'extract': + $this->stepExtract($session, $restoreState); + break; + + case 'files': + $this->stepFiles($session, $restoreState); + break; + + case 'database': + $this->stepDatabase($session, $restoreState); + break; + + case 'config': + $this->stepConfig($session, $restoreState); + break; + + case 'cleanup': + $this->stepCleanup($session, $restoreState); + break; + + case 'complete': + $this->destroyRestoreState($sessionId); + $session->destroy(); + + return [ + 'session_id' => $sessionId, + 'phase' => 'complete', + 'progress' => 100, + 'message' => 'Restore complete: ' . $session->archiveName, + 'done' => true, + ]; + } + + $this->saveRestoreState($sessionId, $restoreState); + $session->save(); + + return [ + 'session_id' => $sessionId, + 'phase' => $session->phase, + 'progress' => $session->getProgress(), + 'message' => $session->statusMessage, + 'done' => $session->phase === 'complete', + ]; + } catch (\Throwable $e) { + $session->log('FATAL: ' . $e->getMessage()); + + // Restore config on failure if we preserved it + if (!empty($restoreState['config_backup']) && $restoreState['preserve_config']) { + @file_put_contents(JPATH_ROOT . '/configuration.php', $restoreState['config_backup']); + $session->log('Configuration.php restored after failure'); + } + + // Clean up staging on failure + $stagingDir = $restoreState['staging_dir'] ?? ''; + + if (!empty($stagingDir) && is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + } + + $this->destroyRestoreState($sessionId); + $session->destroy(); + + return ['error' => true, 'message' => 'Restore failed: ' . $e->getMessage()]; + } + } + + /** + * Extract phase: extract archive to staging directory. + */ + private function stepExtract(SteppedSession $session, array &$state): void + { + $stagingDir = $state['staging_dir']; + $archivePath = $session->archivePath; + $password = $state['password']; + + // Clean existing staging dir + if (is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + } + + if (!mkdir($stagingDir, 0755, true)) { + throw new \RuntimeException('Cannot create staging directory: ' . $stagingDir); + } + + $session->log('Extracting archive: ' . basename($archivePath)); + + // Detect format and extract + if (JpaUnarchiver::isJpaFile($archivePath)) { + $session->log('Detected JPA format (Akeeba Backup archive)'); + $jpa = new JpaUnarchiver($archivePath, $stagingDir); + $count = $jpa->extract(); + $session->log('Extracted ' . $count . ' files from JPA'); + } elseif (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) { + $session->log('Detected tar.gz format'); + $phar = new \PharData($archivePath); + + // Validate entries for path traversal + foreach (new \RecursiveIteratorIterator($phar) as $entry) { + $entryName = $entry->getPathname(); + $relative = substr($entryName, strlen('phar://' . $archivePath) + 1); + + if (str_contains($relative, '../') || str_contains($relative, '..\\') + || str_starts_with($relative, '/') || str_starts_with($relative, '\\')) { + throw new \RuntimeException('Archive contains unsafe path: ' . $relative); + } + } + + $phar->extractTo($stagingDir, null, true); + $session->log('Extracted tar.gz archive'); + } else { + $this->extractZipArchive($archivePath, $stagingDir, $password, $session); + } + + $session->log('Extraction complete'); + + // Preserve configuration.php before any files are copied + if ($state['preserve_config'] && is_file(JPATH_ROOT . '/configuration.php')) { + $state['config_backup'] = file_get_contents(JPATH_ROOT . '/configuration.php'); + $session->log('Current configuration.php preserved'); + } + + // Build file list for the files phase + if ($state['restore_files']) { + $fileList = $this->scanStagingFiles($stagingDir); + $state['file_list'] = $fileList; + $state['file_index'] = 0; + + $fileBatches = (int) ceil(count($fileList) / self::FILE_BATCH_SIZE); + $session->log('Files to restore: ' . count($fileList) . ' (' . $fileBatches . ' batches)'); + } + + // Check for SQL file + $sqlFile = $stagingDir . '/database.sql'; + + if ($state['restore_db'] && is_file($sqlFile)) { + $state['sql_file'] = $sqlFile; + $state['sql_offset'] = 0; + $state['sql_done'] = false; + + // Estimate SQL batches by counting lines + $lineCount = 0; + $fh = fopen($sqlFile, 'r'); + + if ($fh) { + while (fgets($fh) !== false) { + $lineCount++; + } + + fclose($fh); + } + + // Rough estimate: each statement ~2 lines on average + $estimatedStatements = max(1, (int) ($lineCount / 2)); + $sqlBatches = (int) ceil($estimatedStatements / self::SQL_BATCH_SIZE); + $session->log('SQL file found: ~' . $estimatedStatements . ' statements (' . $sqlBatches . ' batches)'); + } elseif ($state['restore_db']) { + $session->log('No database.sql found in archive — skipping database restore'); + $state['restore_db'] = false; + } + + // Recalculate total steps now that we know the actual counts + $totalSteps = 1; // extract (done) + + if ($state['restore_files']) { + $totalSteps += max(1, (int) ceil(count($state['file_list']) / self::FILE_BATCH_SIZE)); + } + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $totalSteps += max(1, $sqlBatches ?? 1); + } + + $totalSteps += 1; // config + $totalSteps += 1; // cleanup + + $session->totalSteps = $totalSteps; + $session->currentStep = 1; + + // Move to next phase + if ($state['restore_files']) { + $session->phase = 'files'; + } elseif ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + + $session->statusMessage = 'Archive extracted — starting restore...'; + } + + /** + * Files phase: copy a batch of files from staging to JPATH_ROOT. + */ + private function stepFiles(SteppedSession $session, array &$state): void + { + $fileList = $state['file_list']; + $fileIndex = $state['file_index']; + $stagingDir = $state['staging_dir']; + $totalFiles = count($fileList); + + if ($fileIndex >= $totalFiles) { + // Files phase complete + $session->log('Files phase complete: ' . $totalFiles . ' files restored'); + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + + return; + } + + $batchEnd = min($fileIndex + self::FILE_BATCH_SIZE, $totalFiles); + $copied = 0; + $sourceBase = rtrim($stagingDir, '/\\'); + $targetBase = rtrim(JPATH_ROOT, '/\\'); + + // Files that should never be overwritten during restore + $skipFiles = ['configuration.php', 'configuration.php.bak', '.htaccess', 'web.config']; + $excludeFiles = ['database.sql']; + + for ($i = $fileIndex; $i < $batchEnd; $i++) { + $relativePath = $fileList[$i]; + $sourcePath = $sourceBase . '/' . $relativePath; + $targetPath = $targetBase . '/' . $relativePath; + $basename = basename($relativePath); + $dirPart = dirname($relativePath); + + // Skip excluded files + if (in_array($basename, $excludeFiles, true)) { + continue; + } + + // Skip protected files at root level + if (($dirPart === '' || $dirPart === '.') && in_array($basename, $skipFiles, true)) { + continue; + } + + if (!is_file($sourcePath)) { + continue; + } + + // Ensure parent directory exists + $parentDir = dirname($targetPath); + + if (!is_dir($parentDir)) { + mkdir($parentDir, 0755, true); + } + + if (copy($sourcePath, $targetPath)) { + $perms = fileperms($sourcePath); + + if ($perms !== false) { + @chmod($targetPath, $perms); + } + + $copied++; + } + } + + $state['file_index'] = $batchEnd; + + $session->currentStep++; + $batchNum = (int) ceil($batchEnd / self::FILE_BATCH_SIZE); + $totalBatch = (int) ceil($totalFiles / self::FILE_BATCH_SIZE); + $session->statusMessage = "Restoring files batch {$batchNum}/{$totalBatch} ({$copied} files copied)"; + $session->log("Files batch {$batchNum}: {$copied} files copied ({$batchEnd}/{$totalFiles})"); + + // Check if we're done with files + if ($batchEnd >= $totalFiles) { + $session->log('Files phase complete: ' . $totalFiles . ' files processed'); + + if ($state['restore_db'] && !empty($state['sql_file'])) { + $session->phase = 'database'; + } else { + $session->phase = 'config'; + } + } + } + + /** + * Database phase: import SQL statements in batches. + */ + private function stepDatabase(SteppedSession $session, array &$state): void + { + if ($state['sql_done'] || empty($state['sql_file'])) { + $session->log('Database phase complete: ' . $state['sql_executed'] . ' statements executed'); + $session->phase = 'config'; + + return; + } + + $sqlFile = $state['sql_file']; + $offset = $state['sql_offset']; + + $db = Factory::getDbo(); + $prefix = $db->getPrefix(); + + $handle = fopen($sqlFile, 'r'); + + if ($handle === false) { + throw new \RuntimeException('Cannot open SQL file: ' . $sqlFile); + } + + // Seek to the byte offset where we left off + if ($offset > 0) { + fseek($handle, $offset); + } + + $statementsExecuted = 0; + $currentStatement = ''; + $inMultiLineComment = false; + + while (($line = fgets($handle)) !== false) { + $trimmed = trim($line); + + // Skip empty lines + if ($trimmed === '') { + continue; + } + + // Skip single-line comments + if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) { + continue; + } + + // Handle multi-line comments + if (str_starts_with($trimmed, '/*')) { + $inMultiLineComment = true; + } + + if ($inMultiLineComment) { + if (str_contains($trimmed, '*/')) { + $inMultiLineComment = false; + } + + continue; + } + + // Accumulate the statement + $currentStatement .= $line; + + // Check if statement is complete (ends with semicolon) + if (str_ends_with($trimmed, ';')) { + $statement = trim($currentStatement); + $currentStatement = ''; + + if (empty($statement)) { + continue; + } + + // Replace abstract #__ prefix with the current site's prefix + $statement = str_replace('#__', $prefix, $statement); + + try { + $db->setQuery($statement); + $db->execute(); + } catch (\Exception $e) { + error_log('MokoSuiteBackup SQL import warning: ' . $e->getMessage()); + } + + $statementsExecuted++; + $state['sql_executed']++; + + // Check if we've hit the batch limit + if ($statementsExecuted >= self::SQL_BATCH_SIZE) { + $state['sql_offset'] = ftell($handle); + fclose($handle); + + $session->currentStep++; + $session->statusMessage = 'Importing database... (' . $state['sql_executed'] . ' statements executed)'; + $session->log('Database batch: ' . $statementsExecuted . ' statements (total: ' . $state['sql_executed'] . ')'); + + return; + } + } + } + + // Handle any remaining statement without trailing semicolon + $remaining = trim($currentStatement); + + if (!empty($remaining)) { + $remaining = str_replace('#__', $prefix, $remaining); + + try { + $db->setQuery($remaining); + $db->execute(); + $state['sql_executed']++; + } catch (\Exception $e) { + error_log('MokoSuiteBackup SQL import warning (final): ' . $e->getMessage()); + } + } + + fclose($handle); + + $state['sql_done'] = true; + $session->currentStep++; + $session->phase = 'config'; + $session->statusMessage = 'Database import complete: ' . $state['sql_executed'] . ' statements'; + $session->log('Database import complete: ' . $state['sql_executed'] . ' statements executed'); + } + + /** + * Config phase: restore preserved configuration.php. + */ + private function stepConfig(SteppedSession $session, array &$state): void + { + if ($state['preserve_config'] && !empty($state['config_backup'])) { + file_put_contents(JPATH_ROOT . '/configuration.php', $state['config_backup']); + $session->log('Configuration.php restored to pre-restore state'); + } + + $session->currentStep++; + $session->phase = 'cleanup'; + $session->statusMessage = 'Configuration restored — cleaning up...'; + } + + /** + * Cleanup phase: remove staging directory. + */ + private function stepCleanup(SteppedSession $session, array &$state): void + { + $stagingDir = $state['staging_dir']; + + if (!empty($stagingDir) && is_dir($stagingDir)) { + $this->recursiveDelete($stagingDir); + $session->log('Staging directory cleaned up'); + } + + $session->currentStep++; + $session->phase = 'complete'; + $session->statusMessage = 'Restore complete: ' . $session->archiveName; + $session->log('Restore complete'); + } + + /** + * Extract a ZIP archive to the staging directory with path traversal protection. + */ + private function extractZipArchive(string $archivePath, string $stagingDir, string $password, SteppedSession $session): void + { + $zip = new \ZipArchive(); + $result = $zip->open($archivePath); + + if ($result !== true) { + throw new \RuntimeException('Cannot open archive (error code: ' . $result . ')'); + } + + if (!empty($password)) { + $zip->setPassword($password); + $session->log('Decryption password set'); + } + + // Validate all entries before extraction (path traversal protection) + for ($i = 0; $i < $zip->numFiles; $i++) { + $entryName = $zip->getNameIndex($i); + + if ($entryName === false) { + continue; + } + + if (str_contains($entryName, '../') || str_contains($entryName, '..\\') + || str_starts_with($entryName, '/') || str_starts_with($entryName, '\\')) { + $zip->close(); + throw new \RuntimeException('Archive contains unsafe path: ' . $entryName); + } + } + + if (!$zip->extractTo($stagingDir)) { + $zip->close(); + + throw new \RuntimeException( + 'Failed to extract archive. ' + . (!empty($password) ? 'Check that the decryption password is correct.' : 'The archive may be encrypted — provide a password.') + ); + } + + $session->log('Extracted ' . $zip->numFiles . ' entries'); + $zip->close(); + } + + /** + * Scan the staging directory and return a flat list of relative file paths. + */ + private function scanStagingFiles(string $stagingDir): array + { + $files = []; + $baseLen = strlen(rtrim($stagingDir, '/\\')) + 1; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($stagingDir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $item) { + if ($item->isFile()) { + $relativePath = substr($item->getPathname(), $baseLen); + // Normalise directory separators + $relativePath = str_replace('\\', '/', $relativePath); + $files[] = $relativePath; + } + } + + return $files; + } + + /** + * Recursively delete a directory and all its contents. + */ + private function recursiveDelete(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if ($item->isDir()) { + @rmdir($item->getPathname()); + } else { + @unlink($item->getPathname()); + } + } + + @rmdir($dir); + } + + /** + * Save restore-specific state to a JSON file alongside the session. + */ + private function saveRestoreState(string $sessionId, array $state): void + { + $path = $this->getRestoreStatePath($sessionId); + + if (file_put_contents($path, json_encode($state, JSON_PRETTY_PRINT)) === false) { + throw new \RuntimeException('Cannot save restore state: ' . $path); + } + } + + /** + * Load restore-specific state from disk. + */ + private function loadRestoreState(string $sessionId): ?array + { + $path = $this->getRestoreStatePath($sessionId); + + if (!is_file($path)) { + return null; + } + + $data = json_decode(file_get_contents($path), true); + + return is_array($data) ? $data : null; + } + + /** + * Delete restore state file. + */ + private function destroyRestoreState(string $sessionId): void + { + $path = $this->getRestoreStatePath($sessionId); + + if (is_file($path)) { + @unlink($path); + } + } + + /** + * Get the file path for restore-specific state. + */ + private function getRestoreStatePath(string $sessionId): string + { + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $sessionId); + $dir = JPATH_ROOT . '/tmp/mokosuitebackup-sessions'; + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new \RuntimeException('Cannot create session directory: ' . $dir); + } + } + + return $dir . '/' . $safe . '.restore.json'; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php index 2907eec..8fe51ea 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedSession.php @@ -55,6 +55,10 @@ class SteppedSession public bool $remoteKeepLocal = true; public string $encryptionPassword = ''; + // Multi-remote destinations (loaded from #__mokosuitebackup_remotes) + public array $remoteDestinations = []; + public int $remoteIndex = 0; + // Progress public int $totalSteps = 0; public int $currentStep = 0; diff --git a/source/packages/com_mokosuitebackup/src/Field/FolderPickerField.php b/source/packages/com_mokosuitebackup/src/Field/FolderPickerField.php index cb455b7..1d78f48 100644 --- a/source/packages/com_mokosuitebackup/src/Field/FolderPickerField.php +++ b/source/packages/com_mokosuitebackup/src/Field/FolderPickerField.php @@ -38,7 +38,30 @@ class FolderPickerField extends FormField } // 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 = ''; try { @@ -52,15 +75,15 @@ class FolderPickerField extends FormField $placeholders = [ '[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(), '[HOME]' => BackupDirectory::getHomeDirectory(), - '[host]' => $hostname, - '[site_name]' => $sanitizedSiteName ?: 'joomla', - '[profile_id]' => '1', - '[profile_name]' => 'default', - '[type]' => 'full', - '[year]' => date('Y'), - '[month]' => date('m'), - '[day]' => date('d'), - '[date]' => date('Ymd'), + '[HOST]' => $hostname, + '[SITE_NAME]' => $sanitizedSiteName ?: 'joomla', + '[PROFILE_ID]' => '1', + '[PROFILE_NAME]' => 'default', + '[TYPE]' => 'full', + '[YEAR]' => date('Y'), + '[MONTH]' => date('m'), + '[DAY]' => date('d'), + '[DATE]' => date('Ymd'), ]; $placeholdersJson = json_encode($placeholders); @@ -96,51 +119,140 @@ class FolderPickerField extends FormField Browse -
+
+ Insert: + + + + + + + + +
{$statusDetail}
+
+