Compare commits

..

2 Commits

Author SHA1 Message Date
gitea-actions[bot] 9db7331a72 chore(version): pre-release bump to 01.28.01-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 5s
2026-06-21 23:58:07 +00:00
gitea-actions[bot] 32931c1e37 chore(version): auto-bump patch 01.27.04-dev [skip ci] 2026-06-21 23:57:56 +00:00
24 changed files with 68 additions and 1142 deletions
-9
View File
@@ -30,15 +30,6 @@ on:
types: [opened, closed]
branches:
- main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch:
inputs:
action:
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.31.00
# VERSION: 01.28.01
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+8 -20
View File
@@ -1,26 +1,14 @@
# Changelog
## [Unreleased]
## [01.31.00] --- 2026-06-22
## [01.31.00] --- 2026-06-22
### Added
- REST API endpoints for content snapshots: list, create, restore, delete, download (#54)
- Automatic archive integrity verification after backup creation (#65)
- CLI command `mokosuitebackup:snapshot` for create, restore, list, and delete operations (#55)
## [01.30.00] --- 2026-06-22
## [01.30.00] --- 2026-06-22
### Changed
- Remote upload failure no longer marks the entire backup as failed — local archive is preserved with status 'complete' (#66)
### Added
- Snapshots now capture tags, custom fields, field values, and field-category mappings when articles are included (#57)
- Snapshot retention settings: max count and max age with automatic cleanup (#63)
## [01.27.03] --- 2026-06-21
## [01.27.03] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
## [01.27.00] --- 2026-06-21
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.31.00 -->
<!-- VERSION: 01.28.01 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -1,307 +0,0 @@
<?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,27 +118,6 @@
/>
</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">
<field
name="notify_email"
@@ -269,13 +269,6 @@ COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_SUCCESS_DESC="Send email when any backup comple
COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE="Notify on Failure"
COM_MOKOJOOMBACKUP_CONFIG_NOTIFY_FAILURE_DESC="Send email when any backup fails (unless overridden by profile)."
; Snapshot Retention
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_RETENTION="Snapshot Retention"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT="Max Snapshot Count"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_COUNT_DESC="Maximum number of content snapshots to keep. Oldest are removed first. Set to 0 for unlimited."
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE="Max Snapshot Age (days)"
COM_MOKOJOOMBACKUP_CONFIG_SNAPSHOT_MAX_AGE_DESC="Delete snapshots older than this many days. Set to 0 for unlimited."
; Web Cron
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON="Web Cron"
COM_MOKOJOOMBACKUP_CONFIG_WEBCRON_ENABLED="Enable Web Cron"
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -232,11 +232,6 @@ class BackupEngine
$this->log('Archive created: ' . $sizeHuman);
$this->log('SHA-256: ' . ($checksum ?: 'N/A'));
// Verify archive integrity
$this->log('Verifying archive integrity...');
$this->verifyArchive($archivePath, $profile->backup_type);
$this->log('Archive integrity verified');
// Step 2.5: Wrap with MokoRestore script (if enabled)
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
@@ -260,36 +255,26 @@ class BackupEngine
}
$remoteFilename = '';
$uploadFailed = false;
// 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';
if ($remoteStorage !== 'none') {
try {
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
$this->log('Starting remote upload (' . $remoteStorage . ')...');
$uploader = $this->createUploader($remoteStorage, $profile);
$uploadResult = $uploader->upload($archivePath, $archiveName);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
if ($uploadResult['success']) {
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
$this->log('Remote upload complete: ' . $uploadResult['message']);
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} else {
$uploadFailed = true;
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
// Delete local copy if configured
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
@unlink($archivePath);
$this->log('Local copy removed (remote_keep_local = off)');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$this->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
} else {
$this->log('WARNING: Remote upload failed: ' . $uploadResult['message']);
$this->log('Local backup is preserved.');
}
}
@@ -324,14 +309,9 @@ class BackupEngine
$db->updateObject('#__mokosuitebackup_records', $update, 'id');
// Send success notification (backup completed, even if upload failed)
// Send success notification
NotificationSender::send($profile, $update, true, implode("\n", $this->log));
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $update, false, "Remote upload failed — see backup log for details.\n\n" . implode("\n", $this->log));
}
// Dispatch event for actionlog and other listeners
$this->dispatchAfterRun(true, $recordId, $description, $profileId, $origin);
@@ -523,90 +503,6 @@ class BackupEngine
$zip->close();
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION));
// Detect tar.gz (pathinfo only returns 'gz')
if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) {
$this->verifyTarGzArchive($archivePath);
return;
}
// ZIP verification
$zip = new \ZipArchive();
if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) {
throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file');
}
if ($zip->numFiles < 1) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: archive contains no files');
}
// Verify database.sql exists when backup includes database
if ($backupType !== 'files') {
if ($zip->locateName('database.sql') === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive');
}
}
// Spot-check: verify the first entry is readable
$firstName = $zip->getNameIndex(0);
if ($firstName === false) {
$zip->close();
throw new \RuntimeException('Archive integrity check failed: cannot read first entry');
}
$zip->close();
}
/**
* Verify a tar.gz archive can be opened and iterated.
*
* @param string $archivePath Absolute path to the .tar.gz file
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyTarGzArchive(string $archivePath): void
{
try {
$phar = new \PharData($archivePath);
$count = 0;
foreach ($phar as $entry) {
// Spot-check: verify at least the first entry is accessible
$entry->getFilename();
$count++;
break;
}
if ($count === 0) {
throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries');
}
} catch (\RuntimeException $e) {
throw $e;
} catch (\Throwable $e) {
throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage());
}
}
/**
* Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react.
*/
@@ -41,10 +41,6 @@ class SnapshotEngine
private const ARTICLE_RELATED = [
'#__workflow_associations',
'#__contentitem_tag_map',
'#__tags',
'#__fields',
'#__fields_values',
'#__fields_categories',
];
/**
@@ -111,32 +107,6 @@ class SnapshotEngine
$rows = $this->dumpTagMap($db, $prefix);
$data['tables']['#__contentitem_tag_map'] = $rows;
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
// Tags — dump all (shared, small table)
$rows = $this->dumpTable($db, str_replace('#__', $prefix, '#__tags'), '#__tags', 'articles');
$data['tables']['#__tags'] = $rows;
$this->log(' #__tags: ' . count($rows) . ' rows');
// Custom fields — only com_content.article context
$rows = $this->dumpFilteredTable(
$db,
str_replace('#__', $prefix, '#__fields'),
'#__fields',
'context',
'com_content.article'
);
$data['tables']['#__fields'] = $rows;
$this->log(' #__fields: ' . count($rows) . ' rows');
// Field values — only for com_content.article fields (table is shared across extensions)
$rows = $this->dumpFieldValues($db, $prefix);
$data['tables']['#__fields_values'] = $rows;
$this->log(' #__fields_values: ' . count($rows) . ' rows');
// Field-category mappings — only for com_content.article fields
$rows = $this->dumpFieldCategories($db, $prefix);
$data['tables']['#__fields_categories'] = $rows;
$this->log(' #__fields_categories: ' . count($rows) . ' rows');
}
// Count items
@@ -261,52 +231,6 @@ class SnapshotEngine
return $db->loadAssocList() ?: [];
}
/**
* Dump field-category mappings for com_content.article fields.
*
* Uses a subquery: field_id IN (SELECT id FROM #__fields WHERE context = 'com_content.article')
*/
/**
* Dump field values only for com_content.article fields.
*/
private function dumpFieldValues(object $db, string $prefix): array
{
$fvTable = $prefix . 'fields_values';
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($fvTable))
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function dumpFieldCategories(object $db, string $prefix): array
{
$fcTable = $prefix . 'fields_categories';
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($fcTable))
->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
@@ -33,10 +33,6 @@ class SnapshotRestoreEngine
'#__contentitem_tag_map' => null, // composite key, handled specially
'#__modules' => 'id',
'#__modules_menu' => null, // composite key, handled specially
'#__tags' => 'id',
'#__fields' => 'id',
'#__fields_values' => null, // composite key, handled specially
'#__fields_categories' => null, // composite key, handled specially
];
/**
@@ -286,48 +282,6 @@ class SnapshotRestoreEngine
$query->where($db->quoteName('moduleid') . ' IN (' . implode(',', $moduleIds) . ')');
break;
case '#__tags':
// Only delete tags that exist in the snapshot — never wipe all tags
$ids = array_filter(array_column($rows, 'id'));
if (empty($ids)) {
return;
}
$ids = array_map('intval', $ids);
$query->where($db->quoteName('id') . ' IN (' . implode(',', $ids) . ')');
break;
case '#__fields':
// Only delete custom fields scoped to com_content.article
$query->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
break;
case '#__fields_values':
// Only delete field values for com_content.article fields
$prefix = $db->getPrefix();
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
break;
case '#__fields_categories':
// Delete field-category mappings for com_content.article fields only
$prefix = $db->getPrefix();
$fTable = $prefix . 'fields';
$subQuery = $db->getQuery(true)
->select($db->quoteName('id'))
->from($db->quoteName($fTable))
->where($db->quoteName('context') . ' = ' . $db->quote('com_content.article'));
$query->where($db->quoteName('field_id') . ' IN (' . $subQuery . ')');
break;
// #__content and #__content_frontpage are fully owned by com_content
default:
break;
@@ -349,10 +303,6 @@ class SnapshotRestoreEngine
$tables[] = '#__content_frontpage';
$tables[] = '#__workflow_associations';
$tables[] = '#__contentitem_tag_map';
$tables[] = '#__tags';
$tables[] = '#__fields';
$tables[] = '#__fields_values';
$tables[] = '#__fields_categories';
}
if (in_array('categories', $types)) {
@@ -347,11 +347,6 @@ class SteppedBackupEngine
$totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0;
// Verify archive integrity
$session->log('Verifying archive integrity...');
$this->verifyArchive($session->archivePath, $session->backupType);
$session->log('Archive integrity verified');
// MokoRestore wrapper
if ($session->includeMokoRestore) {
$session->log('Wrapping with MokoRestore script...');
@@ -394,47 +389,37 @@ class SteppedBackupEngine
private function stepUpload(SteppedSession $session): void
{
$db = Factory::getDbo();
// Reload profile for remote settings
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $session->profileId);
$db->setQuery($query);
$profile = $db->loadObject();
$uploader = match ($session->remoteStorage) {
'ftp' => new FtpUploader($profile),
'google_drive' => new GoogleDriveUploader($profile),
's3' => new S3Uploader($profile),
default => throw new \InvalidArgumentException('Unknown storage: ' . $session->remoteStorage),
};
$session->log('Starting remote upload (' . $session->remoteStorage . ')...');
$result = $uploader->upload($session->archivePath, $session->archiveName);
$remoteFilename = '';
$uploadFailed = false;
// Wrapped in its own try-catch so a remote failure does not mark
// the entire backup as failed — the local archive is preserved.
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 ($result['success']) {
$remoteFilename = $result['remote_path'] ?? $session->archiveName;
$session->log('Remote upload complete: ' . $result['message']);
$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);
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.');
if (!$session->remoteKeepLocal && is_file($session->archivePath)) {
@unlink($session->archivePath);
$session->log('Local copy removed');
}
} catch (\Throwable $e) {
$uploadFailed = true;
$session->log('WARNING: Remote upload threw an exception: ' . $e->getMessage());
$session->log('Local backup is preserved.');
} else {
$session->log('WARNING: Remote upload failed: ' . $result['message']);
}
// Update record with remote filename
@@ -448,60 +433,14 @@ class SteppedBackupEngine
$session->currentStep++;
$session->phase = 'complete';
$session->statusMessage = $uploadFailed
? 'Backup complete (remote upload failed — local archive preserved)'
: 'Backup complete';
$this->completeRecord($session, $uploadFailed);
}
/**
* Verify that a backup archive can be opened and contains expected entries.
*
* @param string $archivePath Absolute path to the archive file
* @param string $backupType Backup type: full, database, files, differential
*
* @throws \RuntimeException If the archive fails verification
*/
private function verifyArchive(string $archivePath, string $backupType): void
{
if (!is_file($archivePath)) {
throw new \RuntimeException('Archive file does not exist: ' . $archivePath);
}
$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();
$session->statusMessage = 'Backup complete';
$this->completeRecord($session);
}
/**
* Mark the backup record as complete.
*/
private function completeRecord(SteppedSession $session, bool $uploadFailed = false): void
private function completeRecord(SteppedSession $session): void
{
$db = Factory::getDbo();
$logContent = implode("\n", $session->log);
@@ -551,11 +490,6 @@ class SteppedBackupEngine
];
NotificationSender::send($profile, $record, true, $logContent);
// If remote upload failed, also send a failure notification for the upload
if ($uploadFailed) {
NotificationSender::send($profile, $record, false, "Remote upload failed — see backup log for details.\n\n" . $logContent);
}
}
} catch (\Throwable $e) {
error_log('MokoSuiteBackup: SteppedBackupEngine notification failed: ' . $e->getMessage());
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,268 +0,0 @@
<?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,7 +20,6 @@ use Joomla\Plugin\Console\MokoSuiteBackup\Command\ListCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\ProfilesCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand;
use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand;
final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface
{
@@ -42,6 +41,5 @@ final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterf
$app->addCommand(new ProfilesCommand());
$app->addCommand(new RestoreCommand());
$app->addCommand(new CleanupCommand());
$app->addCommand(new SnapshotCommand());
}
}
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -136,7 +136,6 @@ final class MokoSuiteBackup extends CMSPlugin implements SubscriberInterface
$session->set('mokosuitebackup.last_cleanup', time());
$this->cleanupOldBackups();
$this->cleanupOldSnapshots();
}
/**
@@ -153,93 +152,6 @@ 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
{
$db = Factory::getDbo();
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -9,19 +9,12 @@
*
* REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server.
*
* Backup routes:
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
* GET /api/index.php/v1/mokosuitebackup/backups — List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
*
* Snapshot routes:
* GET /api/index.php/v1/mokosuitebackup/snapshots — List snapshots
* POST /api/index.php/v1/mokosuitebackup/snapshot — Create snapshot
* POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore — Restore snapshot
* DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id — Delete snapshot
* GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download — Download snapshot JSON
* Akeeba-compatible routes:
* POST /api/index.php/v1/mokosuitebackup/backup — Start backup
* GET /api/index.php/v1/mokosuitebackup/backups — List records
* DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record
* GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive
* GET /api/index.php/v1/mokosuitebackup/profiles — List profiles
*/
namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension;
@@ -101,62 +94,5 @@ final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberIn
$defaults
)
);
// --- Snapshot routes ---
// List snapshots (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshots',
'snapshots.displayList',
[],
$defaults
)
);
// Create a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot',
'snapshots.create',
[],
$defaults
)
);
// Restore a snapshot (POST)
$router->addRoute(
new Route(
['POST'],
'v1/mokosuitebackup/snapshot/:id/restore',
'snapshots.restore',
['id' => '(\d+)'],
$defaults
)
);
// Delete a snapshot (DELETE)
$router->addRoute(
new Route(
['DELETE'],
'v1/mokosuitebackup/snapshot/:id',
'snapshots.delete',
['id' => '(\d+)'],
$defaults
)
);
// Download a snapshot JSON file (GET)
$router->addRoute(
new Route(
['GET'],
'v1/mokosuitebackup/snapshot/:id/download',
'snapshots.download',
['id' => '(\d+)'],
$defaults
)
);
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.31.00</version>
<version>01.28.01</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>