Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c7e974b5 | |||
| 6d47b70aaf | |||
| 01bed8942c | |||
| 391047d8e5 | |||
| 5a672454ad | |||
| ed799217bf | |||
| 5f0f958aca | |||
| 7bf42f1a89 | |||
| a919d52cf7 | |||
| a7e94467ee | |||
| 01335ac70f | |||
| 35b7e2a0b8 | |||
| c72e950a25 | |||
| 5dcba6d8cb | |||
| 0638c2cef6 | |||
| fc0c1b05a6 | |||
| 3547667158 | |||
| b882e8ba90 | |||
| db2beef189 | |||
| b0629f9f30 | |||
| b3d955e1a8 | |||
| f5e8d0fe03 | |||
| 5815a65a39 | |||
| ad1c0cf349 | |||
| 8b6e260b28 | |||
| eb7f48d3a2 | |||
| 974b971340 |
@@ -30,6 +30,15 @@ on:
|
|||||||
types: [opened, closed]
|
types: [opened, closed]
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '.mokogitea/workflows/**'
|
||||||
|
- '*.md'
|
||||||
|
- 'wiki/**'
|
||||||
|
- '.editorconfig'
|
||||||
|
- '.gitignore'
|
||||||
|
- '.gitattributes'
|
||||||
|
- '.gitmessage'
|
||||||
|
- 'LICENSE'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# FILE INFORMATION
|
# FILE INFORMATION
|
||||||
# DEFGROUP: Gitea.Workflow
|
# DEFGROUP: Gitea.Workflow
|
||||||
# INGROUP: mokocli.Automation
|
# INGROUP: mokocli.Automation
|
||||||
# VERSION: 01.00.00
|
# VERSION: 01.32.00
|
||||||
# BRIEF: Auto-create feature branch when an issue is opened
|
# BRIEF: Auto-create feature branch when an issue is opened
|
||||||
|
|
||||||
name: "Universal: Issue Branch"
|
name: "Universal: Issue Branch"
|
||||||
|
|||||||
+21
-6
@@ -1,14 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [01.27.03] --- 2026-06-21
|
## [01.32.00] --- 2026-06-22
|
||||||
|
|
||||||
## [01.27.03] --- 2026-06-21
|
### 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.27.00] --- 2026-06-21
|
## [01.31.00] --- 2026-06-22
|
||||||
|
|
||||||
## [01.27.00] --- 2026-06-21
|
## [01.31.00] --- 2026-06-22
|
||||||
|
|
||||||
## [01.27.00] --- 2026-06-21
|
### 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.27.00] --- 2026-06-21
|
## [01.30.00] --- 2026-06-22
|
||||||
|
|
||||||
|
## [01.30.00] --- 2026-06-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
|
||||||
|
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MokoSuiteBackup
|
# MokoSuiteBackup
|
||||||
|
|
||||||
<!-- VERSION: 01.27.03 -->
|
<!-- VERSION: 01.32.00 -->
|
||||||
|
|
||||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,27 @@
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset name="snapshot_cleanup" label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_RETENTION">
|
||||||
|
<field
|
||||||
|
name="snapshot_retention_count"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT_DESC"
|
||||||
|
default="20"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<field
|
||||||
|
name="snapshot_retention_days"
|
||||||
|
type="number"
|
||||||
|
label="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE"
|
||||||
|
description="COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE_DESC"
|
||||||
|
default="30"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS">
|
<fieldset name="notifications" label="COM_MOKOJOOMBACKUP_CONFIG_NOTIFICATIONS">
|
||||||
<field
|
<field
|
||||||
name="notify_email"
|
name="notify_email"
|
||||||
|
|||||||
@@ -269,6 +269,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="Notify on Failure"
|
||||||
COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
|
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
|
; Web Cron
|
||||||
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron"
|
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron"
|
||||||
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron"
|
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="component" method="upgrade">
|
<extension type="component" method="upgrade">
|
||||||
<name>MokoSuiteBackup</name>
|
<name>MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ defined('_JEXEC') or die;
|
|||||||
use Joomla\CMS\MVC\Controller\BaseController;
|
use Joomla\CMS\MVC\Controller\BaseController;
|
||||||
use Joomla\CMS\Session\Session;
|
use Joomla\CMS\Session\Session;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
|
||||||
|
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
|
||||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||||
|
|
||||||
class AjaxController extends BaseController
|
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.
|
* Send a JSON response and close the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -232,6 +232,11 @@ class BackupEngine
|
|||||||
$this->log('Archive created: ' . $sizeHuman);
|
$this->log('Archive created: ' . $sizeHuman);
|
||||||
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
|
$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)
|
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
||||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||||
|
|
||||||
@@ -255,26 +260,36 @@ class BackupEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
$remoteFilename = '';
|
$remoteFilename = '';
|
||||||
|
$uploadFailed = false;
|
||||||
|
|
||||||
// Step 3: Remote upload (if configured)
|
// Step 3: Remote upload (if configured)
|
||||||
|
// Wrapped in its own try-catch so a remote failure does not mark
|
||||||
|
// the entire backup as failed — the local archive is preserved.
|
||||||
$remoteStorage = $profile->remote_storage ?? 'none';
|
$remoteStorage = $profile->remote_storage ?? 'none';
|
||||||
|
|
||||||
if ($remoteStorage !== 'none') {
|
if ($remoteStorage !== 'none') {
|
||||||
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
try {
|
||||||
$uploader = $this->createUploader($remoteStorage, $profile);
|
$this->log('Starting remote upload (' . $remoteStorage . ')...');
|
||||||
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
$uploader = $this->createUploader($remoteStorage, $profile);
|
||||||
|
$uploadResult = $uploader->upload($archivePath, $archiveName);
|
||||||
|
|
||||||
if ($uploadResult['success']) {
|
if ($uploadResult['success']) {
|
||||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||||
|
|
||||||
// Delete local copy if configured
|
// Delete local copy if configured
|
||||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||||
@unlink($archivePath);
|
@unlink($archivePath);
|
||||||
$this->log('Local copy removed (remote_keep_local = off)');
|
$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.');
|
||||||
}
|
}
|
||||||
} else {
|
} catch (\Throwable $e) {
|
||||||
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
|
$uploadFailed = true;
|
||||||
|
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||||
$this->log('Local backup is preserved.');
|
$this->log('Local backup is preserved.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,9 +324,14 @@ class BackupEngine
|
|||||||
|
|
||||||
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
|
$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));
|
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
|
// Dispatch event for actionlog and other listeners
|
||||||
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
|
||||||
|
|
||||||
@@ -503,6 +523,90 @@ class BackupEngine
|
|||||||
$zip->close();
|
$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.
|
* 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.
|
* Resolve user group IDs to email addresses of group members.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -146,6 +146,26 @@ class RestoreEngine
|
|||||||
|
|
||||||
$this->log('Restore complete');
|
$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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ class SnapshotEngine
|
|||||||
private const ARTICLE_RELATED = [
|
private const ARTICLE_RELATED = [
|
||||||
'#__workflow_associations',
|
'#__workflow_associations',
|
||||||
'#__contentitem_tag_map',
|
'#__contentitem_tag_map',
|
||||||
|
'#__tags',
|
||||||
|
'#__fields',
|
||||||
|
'#__fields_values',
|
||||||
|
'#__fields_categories',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +111,32 @@ class SnapshotEngine
|
|||||||
$rows = $this->dumpTagMap($db, $prefix);
|
$rows = $this->dumpTagMap($db, $prefix);
|
||||||
$data['tables']['#__contentitem_tag_map'] = $rows;
|
$data['tables']['#__contentitem_tag_map'] = $rows;
|
||||||
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' 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
|
// Count items
|
||||||
@@ -164,6 +194,26 @@ class SnapshotEngine
|
|||||||
|
|
||||||
$this->log('Snapshot record created: ID ' . $record->id);
|
$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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf(
|
'message' => sprintf(
|
||||||
@@ -231,6 +281,52 @@ class SnapshotEngine
|
|||||||
return $db->loadAssocList() ?: [];
|
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() ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
private function log(string $message): void
|
private function log(string $message): void
|
||||||
{
|
{
|
||||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class SnapshotRestoreEngine
|
|||||||
'#__contentitem_tag_map' => null, // composite key, handled specially
|
'#__contentitem_tag_map' => null, // composite key, handled specially
|
||||||
'#__modules' => 'id',
|
'#__modules' => 'id',
|
||||||
'#__modules_menu' => null, // composite key, handled specially
|
'#__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 +151,25 @@ class SnapshotRestoreEngine
|
|||||||
|
|
||||||
$this->log('Restore complete: ' . $totalRows . ' total rows');
|
$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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||||
@@ -282,6 +305,48 @@ class SnapshotRestoreEngine
|
|||||||
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
|
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
|
||||||
break;
|
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
|
// #__content and #__content_frontpage are fully owned by com_content
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -303,6 +368,10 @@ class SnapshotRestoreEngine
|
|||||||
$tables[] = '#__content_frontpage';
|
$tables[] = '#__content_frontpage';
|
||||||
$tables[] = '#__workflow_associations';
|
$tables[] = '#__workflow_associations';
|
||||||
$tables[] = '#__contentitem_tag_map';
|
$tables[] = '#__contentitem_tag_map';
|
||||||
|
$tables[] = '#__tags';
|
||||||
|
$tables[] = '#__fields';
|
||||||
|
$tables[] = '#__fields_values';
|
||||||
|
$tables[] = '#__fields_categories';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array('categories', $types)) {
|
if (in_array('categories', $types)) {
|
||||||
|
|||||||
@@ -347,6 +347,11 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
|
$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
|
// MokoRestore wrapper
|
||||||
if ($session->includeMokoRestore) {
|
if ($session->includeMokoRestore) {
|
||||||
$session->log('Wrapping with MokoRestore script...');
|
$session->log('Wrapping with MokoRestore script...');
|
||||||
@@ -389,37 +394,47 @@ class SteppedBackupEngine
|
|||||||
private function stepUpload(SteppedSession $session): void
|
private function stepUpload(SteppedSession $session): void
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$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 = '';
|
$remoteFilename = '';
|
||||||
|
$uploadFailed = false;
|
||||||
|
|
||||||
if ($result['success']) {
|
// Wrapped in its own try-catch so a remote failure does not mark
|
||||||
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
|
// the entire backup as failed — the local archive is preserved.
|
||||||
$session->log('Remote upload complete: ' . $result['message']);
|
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();
|
||||||
|
|
||||||
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
|
$uploader = match ($session->remoteStorage) {
|
||||||
@unlink($session->archivePath);
|
'ftp' => new FtpUploader($profile),
|
||||||
$session->log('Local copy removed');
|
'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.');
|
||||||
}
|
}
|
||||||
} else {
|
} catch (\Throwable $e) {
|
||||||
$session->log('WARNING: Remote upload failed: ' . $result['message']);
|
$uploadFailed = true;
|
||||||
|
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
|
||||||
|
$session->log('Local backup is preserved.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update record with remote filename
|
// Update record with remote filename
|
||||||
@@ -433,14 +448,60 @@ class SteppedBackupEngine
|
|||||||
|
|
||||||
$session->currentStep++;
|
$session->currentStep++;
|
||||||
$session->phase = 'complete';
|
$session->phase = 'complete';
|
||||||
$session->statusMessage = 'Backup complete';
|
$session->statusMessage = $uploadFailed
|
||||||
$this->completeRecord($session);
|
? '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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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.
|
* Mark the backup record as complete.
|
||||||
*/
|
*/
|
||||||
private function completeRecord(SteppedSession $session): void
|
private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
$logContent = implode("\n", $session->log);
|
$logContent = implode("\n", $session->log);
|
||||||
@@ -490,6 +551,11 @@ class SteppedBackupEngine
|
|||||||
];
|
];
|
||||||
|
|
||||||
NotificationSender::send($profile, $record, true, $logContent);
|
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) {
|
} catch (\Throwable $e) {
|
||||||
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
|
||||||
|
|||||||
@@ -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
|
// View Log modal handler
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
var btn = e.target.closest('.mb-view-log');
|
var btn = e.target.closest('.mb-view-log');
|
||||||
@@ -443,6 +543,18 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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 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;">
|
<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">
|
<extension type="plugin" group="actionlog" method="upgrade">
|
||||||
<name>Action Log - MokoSuiteBackup</name>
|
<name>Action Log - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="console" method="upgrade">
|
<extension type="plugin" group="console" method="upgrade">
|
||||||
<name>Console - MokoSuiteBackup</name>
|
<name>Console - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -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\ProfilesCommand;
|
||||||
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand;
|
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand;
|
||||||
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand;
|
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand;
|
||||||
|
use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand;
|
||||||
|
|
||||||
final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface
|
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 ProfilesCommand());
|
||||||
$app->addCommand(new RestoreCommand());
|
$app->addCommand(new RestoreCommand());
|
||||||
$app->addCommand(new CleanupCommand());
|
$app->addCommand(new CleanupCommand());
|
||||||
|
$app->addCommand(new SnapshotCommand());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="content" method="upgrade">
|
<extension type="plugin" group="content" method="upgrade">
|
||||||
<name>Content - MokoSuiteBackup</name>
|
<name>Content - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-04</creationDate>
|
<creationDate>2026-06-04</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<extension type="plugin" group="quickicon" method="upgrade">
|
<extension type="plugin" group="quickicon" method="upgrade">
|
||||||
<name>Quick Icon - MokoSuiteBackup</name>
|
<name>Quick Icon - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-->
|
-->
|
||||||
<extension type="plugin" group="system" method="upgrade">
|
<extension type="plugin" group="system" method="upgrade">
|
||||||
<name>System - MokoSuiteBackup</name>
|
<name>System - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
|||||||
$session->set('mokosuitebackup.last_cleanup', time());
|
$session->set('mokosuitebackup.last_cleanup', time());
|
||||||
|
|
||||||
$this->cleanupOldBackups();
|
$this->cleanupOldBackups();
|
||||||
|
$this->cleanupOldSnapshots();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +153,93 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove old content snapshots per component retention settings.
|
||||||
|
*
|
||||||
|
* Respects snapshot_retention_days (max age) and snapshot_retention_count
|
||||||
|
* (max number to keep). A value of 0 means unlimited for that setting.
|
||||||
|
*/
|
||||||
|
private function cleanupOldSnapshots(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->doSnapshotCleanup();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('MokoSuiteBackup: cleanupOldSnapshots() failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function doSnapshotCleanup(): void
|
||||||
|
{
|
||||||
|
$db = Factory::getDbo();
|
||||||
|
$params = ComponentHelper::getParams('com_mokosuitebackup');
|
||||||
|
$retentionDays = (int) $params->get('snapshot_retention_days', 30);
|
||||||
|
$retentionCount = (int) $params->get('snapshot_retention_count', 20);
|
||||||
|
|
||||||
|
// Delete snapshots older than retention_days
|
||||||
|
if ($retentionDays > 0) {
|
||||||
|
$cutoff = date('Y-m-d H:i:s', strtotime("-{$retentionDays} days"));
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([$db->quoteName('id'), $db->quoteName('data_file')])
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('created') . ' < ' . $db->quote($cutoff))
|
||||||
|
->order($db->quoteName('created') . ' DESC');
|
||||||
|
$db->setQuery($query);
|
||||||
|
$expired = $db->loadObjectList();
|
||||||
|
|
||||||
|
foreach ($expired as $snapshot) {
|
||||||
|
$this->deleteSnapshotRecord($db, $snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce max count (keep newest)
|
||||||
|
if ($retentionCount > 0) {
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select('COUNT(*)')
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'));
|
||||||
|
$db->setQuery($query);
|
||||||
|
$totalCount = (int) $db->loadResult();
|
||||||
|
|
||||||
|
if ($totalCount > $retentionCount) {
|
||||||
|
$excess = $totalCount - $retentionCount;
|
||||||
|
$query = $db->getQuery(true)
|
||||||
|
->select([$db->quoteName('id'), $db->quoteName('data_file')])
|
||||||
|
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->order($db->quoteName('created') . ' ASC');
|
||||||
|
$db->setQuery($query, 0, $excess);
|
||||||
|
$oldest = $db->loadObjectList();
|
||||||
|
|
||||||
|
foreach ($oldest as $snapshot) {
|
||||||
|
$this->deleteSnapshotRecord($db, $snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a snapshot record and its JSON data file.
|
||||||
|
*/
|
||||||
|
private function deleteSnapshotRecord(object $db, object $snapshot): void
|
||||||
|
{
|
||||||
|
if (!empty($snapshot->data_file) && is_file($snapshot->data_file)) {
|
||||||
|
if (!@unlink($snapshot->data_file)) {
|
||||||
|
error_log('MokoSuiteBackup: Could not delete snapshot file (id=' . $snapshot->id . '): ' . $snapshot->data_file);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->setQuery(
|
||||||
|
$db->getQuery(true)
|
||||||
|
->delete($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||||
|
->where($db->quoteName('id') . ' = ' . (int) $snapshot->id)
|
||||||
|
);
|
||||||
|
$db->execute();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('MokoSuiteBackup: Could not delete snapshot record ' . $snapshot->id . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function doCleanup(): void
|
private function doCleanup(): void
|
||||||
{
|
{
|
||||||
$db = Factory::getDbo();
|
$db = Factory::getDbo();
|
||||||
|
|||||||
@@ -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">
|
<extension type="plugin" group="task" method="upgrade">
|
||||||
<name>Task - MokoSuiteBackup</name>
|
<name>Task - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
|
|||||||
'method' => 'runBackupProfile',
|
'method' => 'runBackupProfile',
|
||||||
'form' => 'run_profile',
|
'form' => 'run_profile',
|
||||||
],
|
],
|
||||||
|
'mokosuitebackup.snapshot' => [
|
||||||
|
'langConstPrefix' => 'PLG_TASK_MOKOJOOMBACKUP_TASK_RUN_SNAPSHOT',
|
||||||
|
'method' => 'runContentSnapshot',
|
||||||
|
'form' => 'run_snapshot',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
@@ -93,4 +98,51 @@ final class MokoSuiteBackupTask extends CMSPlugin implements SubscriberInterface
|
|||||||
|
|
||||||
return Status::KNOCKOUT;
|
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">
|
<extension type="plugin" group="webservices" method="upgrade">
|
||||||
<name>Web Services - MokoSuiteBackup</name>
|
<name>Web Services - MokoSuiteBackup</name>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
+70
-6
@@ -9,12 +9,19 @@
|
|||||||
*
|
*
|
||||||
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
|
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
|
||||||
*
|
*
|
||||||
* Akeeba-compatible routes:
|
* Backup routes:
|
||||||
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
|
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
|
||||||
* GET /api/index.php/v1/mokosuitebackup/backups — List records
|
* GET /api/index.php/v1/mokosuitebackup/backups — List records
|
||||||
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
|
* 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/backup/:id/download — Download archive
|
||||||
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
|
* 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;
|
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
|
||||||
@@ -94,5 +101,62 @@ final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberIn
|
|||||||
$defaults
|
$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
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<extension type="package" method="upgrade">
|
<extension type="package" method="upgrade">
|
||||||
<name>Package - MokoSuiteBackup</name>
|
<name>Package - MokoSuiteBackup</name>
|
||||||
<packagename>mokosuitebackup</packagename>
|
<packagename>mokosuitebackup</packagename>
|
||||||
<version>01.27.03</version>
|
<version>01.32.00</version>
|
||||||
<creationDate>2026-06-02</creationDate>
|
<creationDate>2026-06-02</creationDate>
|
||||||
<author>Moko Consulting</author>
|
<author>Moko Consulting</author>
|
||||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||||
|
|||||||
Reference in New Issue
Block a user