Compare commits

...

25 Commits

Author SHA1 Message Date
gitea-actions[bot] d562e0dc10 chore: promote changelog [Unreleased] → [01.32.00] 2026-06-22 14:28:27 +00:00
gitea-actions[bot] 29c7e974b5 chore(release): build 01.32.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 22s
2026-06-22 14:28:22 +00:00
jmiller 6d47b70aaf Merge pull request 'feat: Scheduled snapshots, restore notifications, stepped restore (#56, #60, #62)' (#91) from feat/batch-56-60-62 into main 2026-06-22 14:28:01 +00:00
Jonathan Miller 01bed8942c feat: AJAX stepped restore engine for large sites (#62)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 32s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 23s
Break restore into phases (extract, files, database, config, cleanup)
executed via AJAX steps to avoid PHP timeout on shared hosting.

- SteppedRestoreEngine with session persistence
- AjaxController restoreInit/restoreStep endpoints
- Restore modal uses AJAX progress instead of synchronous submit
- Files copied in batches of 200, SQL in batches of 500

Closes #62
2026-06-22 09:27:18 -05:00
Jonathan Miller 391047d8e5 feat: email/ntfy notifications for restore operations (#60)
Send notifications when site restores and snapshot create/restore
complete. Uses sendRestoreNotification() with type-specific subjects.
All calls wrapped in try-catch to never break the actual operation.

Closes #60
2026-06-22 09:27:16 -05:00
Jonathan Miller 5a672454ad feat: scheduled task for automated content snapshots (#56)
Add mokosuitebackup.snapshot task type for com_scheduler with params
for content_types and description_format ([date]/[datetime] placeholders).

Closes #56
2026-06-22 09:27:14 -05:00
jmiller ed799217bf chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-22 00:47:57 +00:00
jmiller 5f0f958aca chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-22 00:47:56 +00:00
jmiller 7bf42f1a89 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-22 00:47:56 +00:00
jmiller a919d52cf7 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-22 00:47:55 +00:00
gitea-actions[bot] a7e94467ee chore: promote changelog [Unreleased] → [01.31.00] 2026-06-22 00:47:07 +00:00
gitea-actions[bot] 01335ac70f chore(release): build 01.31.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 26s
2026-06-22 00:47:04 +00:00
jmiller 35b7e2a0b8 Merge pull request 'feat: CLI snapshots, auto-verify integrity, snapshot REST API (#55, #65, #54)' (#90) from feat/batch-55-65-54 into main 2026-06-22 00:46:52 +00:00
Jonathan Miller c72e950a25 feat: REST API endpoints for content snapshots (#54)
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 15s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m49s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
Add five endpoints matching the existing backup API pattern:
- GET /snapshots — list with pagination
- POST /snapshot — create (content_types, description)
- POST /snapshot/:id/restore — restore (mode, content_types)
- DELETE /snapshot/:id — delete record + file
- GET /snapshot/:id/download — stream JSON file

ACL: mokosuitebackup.snapshot.manage for write ops, core.manage for read.
Routes registered in webservices plugin alongside backup routes.

Closes #54
2026-06-21 19:46:07 -05:00
Jonathan Miller 5dcba6d8cb feat: auto-verify backup integrity after creation (#65)
After archive is created and checksum computed, automatically verify:
- Archive opens without error
- Contains at least one entry
- database.sql present when backup type includes database
- First entry is readable (spot-check)

Applied to both BackupEngine and SteppedBackupEngine. Throws
RuntimeException on verification failure (backup marked as failed).

Closes #65
2026-06-21 19:45:46 -05:00
Jonathan Miller 0638c2cef6 feat: CLI command for content snapshots (#55)
Add `mokosuitebackup:snapshot` command with four actions:
- create: --types=articles,categories,modules --description="text"
- restore: --id=N --mode=replace|merge --types=articles
- list: displays table of all snapshots
- delete: --id=N removes file + DB record

Closes #55
2026-06-21 19:45:09 -05:00
jmiller fc0c1b05a6 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-22 00:35:41 +00:00
jmiller 3547667158 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-22 00:35:40 +00:00
jmiller b882e8ba90 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-22 00:35:39 +00:00
jmiller db2beef189 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-22 00:35:37 +00:00
jmiller b0629f9f30 chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-22 00:35:37 +00:00
gitea-actions[bot] b3d955e1a8 chore: promote changelog [Unreleased] → [01.30.00] 2026-06-22 00:34:05 +00:00
gitea-actions[bot] f5e8d0fe03 chore(release): build 01.30.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 34s
2026-06-22 00:34:02 +00:00
jmiller 5815a65a39 Merge pull request 'feat: Snapshot retention, extended snapshots, graceful remote degradation (#63, #57, #66)' (#89) from feat/batch-63-57-66 into main 2026-06-22 00:33:51 +00:00
Jonathan Miller ad1c0cf349 fix: scope #__fields_values dump and restore to com_content.article
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: PR Check / Secret Scan (pull_request) Successful in 7s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 11s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 28s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m55s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
The fields_values table is shared across all Joomla extensions.
Previously, dump captured ALL field values and restore deleted ALL
field values, destroying data for contacts, users, and other
extensions. Now scoped via subquery on field_id WHERE context =
'com_content.article'.
2026-06-21 19:32:23 -05:00
28 changed files with 2212 additions and 32 deletions
+9
View File
@@ -30,6 +30,15 @@ on:
types: [opened, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.00.00
# VERSION: 01.32.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+22 -12
View File
@@ -1,21 +1,31 @@
# Changelog
## [Unreleased]
## [01.32.00] --- 2026-06-22
## [01.32.00] --- 2026-06-22
### Added
- AJAX-based stepped restore engine for large sites — prevents timeout on shared hosting (#62)
- Email/ntfy notifications for site restores and snapshot create/restore operations (#60)
- Scheduled task type `mokosuitebackup.snapshot` for automated content snapshots via com_scheduler (#56)
## [01.31.00] --- 2026-06-22
## [01.31.00] --- 2026-06-22
### Added
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
- Automatic archive integrity verification after backup creation (#65)
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
## [01.30.00] --- 2026-06-22
## [01.30.00] --- 2026-06-22
### Changed
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
### Added
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
## [01.27.03] --- 2026-06-21
## [01.27.03] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.27.03 -->
<!-- VERSION: 01.32.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -0,0 +1,307 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* 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('core.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('core.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;
}
}
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -18,6 +18,7 @@ defined('_JEXEC') or die;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
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
@@ -308,6 +309,74 @@ 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.run', '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.run', '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);
}
/**
* Send a JSON response and close the application.
*/
@@ -232,6 +232,11 @@ class BackupEngine
$this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
// Verify archive integrity
$this->log('Verifying archive integrity...');
$this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified');
// Step 2.5: Wrap with MokoRestore script (if enabled)
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
@@ -518,6 +523,90 @@ 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;
}
// 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());
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -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.
*
@@ -146,6 +146,26 @@ 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());
}
return [
'success' => true,
'message' => 'Restore complete from: ' . basename($archivePath),
@@ -128,8 +128,8 @@ class SnapshotEngine
$data['tables']['#__fields'] = $rows;
$this->log(' #__fields: ' . count($rows) . ' rows');
// Field values — dump all (small, article-scoped)
$rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__fields_values'), '#__fields_values', 'articles');
// 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');
@@ -194,6 +194,26 @@ 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());
}
return [
'success' => true,
'message' => sprintf(
@@ -266,6 +286,28 @@ class SnapshotEngine
*
* 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';
@@ -151,6 +151,25 @@ 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());
}
return [
'success' => true,
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
@@ -304,7 +323,15 @@ class SnapshotRestoreEngine
break;
case '#__fields_values':
// Delete all field values — they are article-scoped
// 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':
@@ -347,6 +347,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...');
@@ -449,6 +454,50 @@ class SteppedBackupEngine
$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);
}
$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();
}
/**
* Mark the backup record as complete.
*/
@@ -0,0 +1,753 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* 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';
}
}
@@ -346,6 +346,106 @@ $listDirn = $this->escape($this->state->get('list.direction'));
}
});
// AJAX stepped restore
var restoreRunning = false;
function showRestoreProgress() {
restoreRunning = true;
document.getElementById('mb-restore-modal').style.display = 'none';
document.getElementById('mb-restore-progress-modal').style.display = 'block';
}
function hideRestoreProgress() {
restoreRunning = false;
document.getElementById('mb-restore-progress-modal').style.display = 'none';
}
function updateRestoreProgress(progress, message, phase) {
var bar = document.getElementById('mb-restore-progress-bar');
bar.style.width = progress + '%';
bar.textContent = progress + '%';
document.getElementById('mb-restore-status').textContent = message;
document.getElementById('mb-restore-phase').textContent = 'Phase: ' + phase;
}
window.addEventListener('beforeunload', function(e) {
if (restoreRunning) {
e.preventDefault();
e.returnValue = '';
}
});
async function startSteppedRestore(e) {
e.preventDefault();
var recordId = document.getElementById('mb-restore-record-id').value;
var restoreFiles = document.getElementById('mb-restore-files').checked ? 1 : 0;
var restoreDb = document.getElementById('mb-restore-db').checked ? 1 : 0;
var preserveConfig = document.getElementById('mb-restore-config').checked ? 1 : 0;
var password = document.getElementById('mb-restore-password').value;
showRestoreProgress();
updateRestoreProgress(0, 'Initializing restore...', 'init');
try {
var initResult = await postAjax({
task: 'ajax.restoreInit',
id: recordId,
restore_files: restoreFiles,
restore_db: restoreDb,
preserve_config: preserveConfig,
encryption_password: password
});
if (initResult.error) {
updateRestoreProgress(0, 'ERROR: ' + initResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
var sessionId = initResult.session_id;
updateRestoreProgress(initResult.progress, initResult.message, initResult.phase);
var done = false;
while (!done) {
var stepResult = await postAjax({
task: 'ajax.restoreStep',
session_id: sessionId
});
if (stepResult.error) {
updateRestoreProgress(0, 'ERROR: ' + stepResult.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
return;
}
updateRestoreProgress(stepResult.progress, stepResult.message, stepResult.phase);
done = stepResult.done || false;
}
document.getElementById('mb-restore-title').textContent = 'Restore Complete';
setTimeout(function() {
hideRestoreProgress();
location.reload();
}, 2000);
} catch (err) {
updateRestoreProgress(0, 'ERROR: ' + err.message, 'failed');
document.getElementById('mb-restore-title').textContent = 'Restore Failed';
setTimeout(hideRestoreProgress, 5000);
}
}
// Attach the AJAX restore handler to the restore form
document.addEventListener('DOMContentLoaded', function() {
var restoreForm = document.getElementById('mb-restore-form');
if (restoreForm) {
restoreForm.addEventListener('submit', startSteppedRestore);
}
});
// View Log modal handler
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mb-view-log');
@@ -443,6 +543,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
</div>
</div>
<!-- Restore Progress Modal -->
<div id="mb-restore-progress-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
<h3 id="mb-restore-title" style="margin:0 0 1rem;">Restore in Progress</h3>
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
<div id="mb-restore-progress-bar" style="height:100%; background:#dc3545; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
</div>
<p id="mb-restore-status" style="color:#666; font-size:0.9rem; margin:0.5rem 0;">Initializing...</p>
<p id="mb-restore-phase" style="color:#999; font-size:0.8rem; margin:0;">Phase: init</p>
</div>
</div>
<!-- Log Viewer Modal -->
<div id="mb-log-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); display:flex; flex-direction:column; max-height:80vh;">
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1,268 @@
<?php
/**
* @package MokoSuiteBackup
* @subpackage plg_console_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*/
namespace Joomla\Plugin\Console\MokoSuiteBackup\Command;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine;
use Joomla\Console\Command\AbstractCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class SnapshotCommand extends AbstractCommand
{
protected static $defaultName = 'mokosuitebackup:snapshot';
protected function configure(): void
{
$this->setDescription('Create, restore, list, or delete content snapshots');
$this->addArgument('action', InputArgument::REQUIRED, 'Action to perform: create, restore, list, delete');
$this->addOption('id', null, InputOption::VALUE_REQUIRED, 'Snapshot ID (required for restore and delete)');
$this->addOption('types', null, InputOption::VALUE_REQUIRED, 'Comma-separated content types: articles,categories,modules', 'articles,categories,modules');
$this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, 'Snapshot description', '');
$this->addOption('mode', null, InputOption::VALUE_REQUIRED, 'Restore mode: replace or merge', 'replace');
}
protected function doExecute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$action = $input->getArgument('action');
$io->title('MokoSuiteBackup — Content Snapshot');
return match ($action) {
'create' => $this->actionCreate($input, $io),
'restore' => $this->actionRestore($input, $io),
'list' => $this->actionList($io),
'delete' => $this->actionDelete($input, $io),
default => $this->actionUnknown($action, $io),
};
}
private function actionCreate(InputInterface $input, SymfonyStyle $io): int
{
$types = array_map('trim', explode(',', $input->getOption('types')));
$description = $input->getOption('description') ?: '';
$io->text('Types: ' . implode(', ', $types));
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotEngine.php';
if (!file_exists($engineFile)) {
$io->error('MokoSuiteBackup component not installed.');
return 1;
}
if (!class_exists(SnapshotEngine::class)) {
require_once $engineFile;
}
$engine = new SnapshotEngine();
$result = $engine->create($types, $description ?: 'CLI snapshot');
if ($result['success']) {
$io->success($result['message']);
if (isset($result['id'])) {
$io->text('Snapshot ID: ' . $result['id']);
}
return 0;
}
$io->error($result['message']);
return 1;
}
private function actionRestore(InputInterface $input, SymfonyStyle $io): int
{
$id = $input->getOption('id');
if (!$id) {
$io->error('The --id option is required for restore.');
return 1;
}
$id = (int) $id;
$mode = $input->getOption('mode');
if (!\in_array($mode, ['replace', 'merge'], true)) {
$io->error('Invalid restore mode. Use "replace" or "merge".');
return 1;
}
$typesRaw = $input->getOption('types');
$contentTypes = ($typesRaw === 'articles,categories,modules')
? []
: array_map('trim', explode(',', $typesRaw));
$io->text('Snapshot ID: ' . $id);
$io->text('Mode: ' . $mode);
if (!empty($contentTypes)) {
$io->text('Types: ' . implode(', ', $contentTypes));
} else {
$io->text('Types: all from snapshot');
}
$io->warning('This will modify your site content.');
if (!$io->confirm('Are you sure you want to continue?', false)) {
$io->info('Restore cancelled.');
return 0;
}
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php';
if (!file_exists($engineFile)) {
$io->error('SnapshotRestoreEngine not found. Is the component fully installed?');
return 1;
}
if (!class_exists(SnapshotRestoreEngine::class)) {
require_once $engineFile;
}
$engine = new SnapshotRestoreEngine();
$result = $engine->restore($id, $mode, $contentTypes);
if ($result['success']) {
$io->success($result['message']);
return 0;
}
$io->error($result['message']);
return 1;
}
private function actionList(SymfonyStyle $io): int
{
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select([
$db->quoteName('id'),
$db->quoteName('description'),
$db->quoteName('content_types'),
$db->quoteName('created'),
$db->quoteName('file_size'),
])
->from($db->quoteName('#__mokosuitebackup_snapshots'))
->order($db->quoteName('id') . ' DESC');
$db->setQuery($query);
$rows = $db->loadObjectList();
if (empty($rows)) {
$io->info('No snapshots found.');
return 0;
}
$tableRows = [];
foreach ($rows as $row) {
$size = isset($row->file_size) ? $this->formatBytes((int) $row->file_size) : '-';
$tableRows[] = [
$row->id,
$row->description ?: '-',
$row->content_types ?: '-',
$row->created,
$size,
];
}
$io->table(
['ID', 'Description', 'Content Types', 'Created', 'Size'],
$tableRows
);
return 0;
}
private function actionDelete(InputInterface $input, SymfonyStyle $io): int
{
$id = $input->getOption('id');
if (!$id) {
$io->error('The --id option is required for delete.');
return 1;
}
$id = (int) $id;
$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) {
$io->error('Snapshot not found: ' . $id);
return 1;
}
// Delete the snapshot file if it exists
if (!empty($record->file_path) && is_file($record->file_path)) {
if (!@unlink($record->file_path)) {
$io->warning('Could not delete snapshot file: ' . $record->file_path);
} else {
$io->text('Deleted file: ' . $record->file_path);
}
}
// Delete the DB record
$query = $db->getQuery(true)
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
->where($db->quoteName('id') . ' = ' . $id);
$db->setQuery($query);
$db->execute();
$io->success('Snapshot #' . $id . ' deleted.');
return 0;
}
private function actionUnknown(string $action, SymfonyStyle $io): int
{
$io->error('Unknown action: ' . $action . '. Valid actions: create, restore, list, delete.');
return 1;
}
private function formatBytes(int $bytes): string
{
if ($bytes === 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) floor(log($bytes, 1024));
return round($bytes / (1024 ** $i), 2) . ' ' . $units[$i];
}
}
@@ -20,6 +20,7 @@ use Joomla\Plugin\Console\MokoSuiteBackup\Command\ListCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\ProfilesCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand;
final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface
{
@@ -41,5 +42,6 @@ final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterf
$app->addCommand(new ProfilesCommand());
$app->addCommand(new RestoreCommand());
$app->addCommand(new CleanupCommand());
$app->addCommand(new SnapshotCommand());
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
* Task form: configure content snapshot parameters.
* This form appears in System > Scheduled Tasks when creating a
* "MokoSuiteBackup: Run Content Snapshot" task.
-->
<form>
<fieldset name="run_snapshot">
<field name="content_types" type="checkboxes" label="Content Types" default="articles,categories,modules">
<option value="articles">Articles</option>
<option value="categories">Categories</option>
<option value="modules">Modules</option>
</field>
<field name="description_format" type="text" label="Description Format" default="[date] Scheduled snapshot" hint="Use [date], [datetime] placeholders" />
</fieldset>
</form>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -43,6 +43,11 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
'method' => 'runBackupProfile',
'form' => 'run_profile',
],
'mokosuitebackup.snapshot' => [
'langConstPrefix' => 'PLG_TASK_MOKOJOOMBACKUP_TASK_RUN_SNAPSHOT',
'method' => 'runContentSnapshot',
'form' => 'run_snapshot',
],
];
public static function getSubscribedEvents(): array
@@ -93,4 +98,51 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
return Status::KNOCKOUT;
}
/**
* Create a content snapshot using the configured content types.
*
* @param ExecuteTaskEvent $event The task execution event
*
* @return int Status::OK on success, Status::KNOCKOUT on failure
*/
private function runContentSnapshot(ExecuteTaskEvent $event): int
{
$params = $event->getArgument('params');
$contentTypes = (array) ($params->content_types ?? ['articles', 'categories', 'modules']);
$descFormat = (string) ($params->description_format ?? '[date] Scheduled snapshot');
// Resolve placeholders in the description
$description = str_replace(
['[date]', '[datetime]'],
[date('Y-m-d'), date('Y-m-d H:i:s')],
$descFormat
);
// Load the snapshot engine from the component
$engineFile = JPATH_ADMINISTRATOR . '/components/com_mokosuitebackup/src/Engine/SnapshotEngine.php';
if (!file_exists($engineFile)) {
$this->logTask('MokoSuiteBackup component not installed — cannot create snapshot.');
return Status::KNOCKOUT;
}
if (!class_exists('\\Joomla\\Component\\MokoSuiteBackup\\Administrator\\Engine\\SnapshotEngine')) {
require_once $engineFile;
}
$engine = new \Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine();
$result = $engine->create($contentTypes, $description);
if ($result['success']) {
$this->logTask('Snapshot complete: ' . $result['message']);
return Status::OK;
}
$this->logTask('Snapshot failed: ' . $result['message']);
return Status::KNOCKOUT;
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -9,12 +9,19 @@
*
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
*
* Akeeba-compatible routes:
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
* GET /api/index.php/v1/mokosuitebackup/backups — List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
* Backup routes:
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
* GET /api/index.php/v1/mokosuitebackup/backups — List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
*
* Snapshot routes:
* 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\Plugin\WebServices\MokoSuiteBackup\Extension;
@@ -94,5 +101,62 @@ final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberIn
$defaults
)
);
// --- Snapshot routes ---
// List snapshots (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshots',
'snapshots.displayList',
[],
$defaults
)
);
// Create a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot',
'snapshots.create',
[],
$defaults
)
);
// Restore a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot/:id/restore',
'snapshots.restore',
['id' => '(\d+)'],
$defaults
)
);
// Delete a snapshot (DELETE)
$router->addRoute(
new Route(
['DELETE'],
'v1/mokosuitebackup/snapshot/:id',
'snapshots.delete',
['id' => '(\d+)'],
$defaults
)
);
// Download a snapshot JSON file (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshot/:id/download',
'snapshots.download',
['id' => '(\d+)'],
$defaults
)
);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.27.03</version>
<version>01.32.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>