diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e7559a..0a89fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### 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 diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php new file mode 100644 index 0000000..92c3eb5 --- /dev/null +++ b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php @@ -0,0 +1,307 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * REST API controller for content snapshot operations. + * + * Endpoints: + * GET /api/index.php/v1/mokosuitebackup/snapshots — List snapshots + * POST /api/index.php/v1/mokosuitebackup/snapshot — Create snapshot + * POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore — Restore snapshot + * DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id — Delete snapshot + * GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download — Download snapshot JSON + */ + +namespace Joomla\Component\MokoSuiteBackup\Api\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Controller\ApiController; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; + +class SnapshotsController extends ApiController +{ + protected $contentType = 'snapshots'; + protected $default_view = 'snapshots'; + + /** + * List all snapshots with pagination (GET /api/index.php/v1/mokosuitebackup/snapshots) + */ + public function displayList(): static + { + if (!$this->app->getIdentity()->authorise('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; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index e45aa94..ae99b0c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -232,6 +232,11 @@ class BackupEngine $this->log('Archive created: ' . $sizeHuman); $this->log('SHA-256: ' . ($checksum ?: 'N/A')); + // Verify archive integrity + $this->log('Verifying archive integrity...'); + $this->verifyArchive($archivePath, $profile->backup_type); + $this->log('Archive integrity verified'); + // Step 2.5: Wrap with MokoRestore script (if enabled) $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); @@ -518,6 +523,90 @@ class BackupEngine $zip->close(); } + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); + } + + $extension = strtolower(pathinfo($archivePath, PATHINFO_EXTENSION)); + + // Detect tar.gz (pathinfo only returns 'gz') + if ($extension === 'gz' && str_ends_with(strtolower($archivePath), '.tar.gz')) { + $this->verifyTarGzArchive($archivePath); + + return; + } + + // ZIP verification + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } + + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); + } + + /** + * Verify a tar.gz archive can be opened and iterated. + * + * @param string $archivePath Absolute path to the .tar.gz file + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyTarGzArchive(string $archivePath): void + { + try { + $phar = new \PharData($archivePath); + $count = 0; + + foreach ($phar as $entry) { + // Spot-check: verify at least the first entry is accessible + $entry->getFilename(); + $count++; + break; + } + + if ($count === 0) { + throw new \RuntimeException('Archive integrity check failed: tar.gz archive contains no entries'); + } + } catch (\RuntimeException $e) { + throw $e; + } catch (\Throwable $e) { + throw new \RuntimeException('Archive integrity check failed: ' . $e->getMessage()); + } + } + /** * Dispatch the onMokoSuiteBackupAfterRun event so plugins (actionlog, etc.) can react. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index e6a0f36..de44718 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -347,6 +347,11 @@ class SteppedBackupEngine $totalSize = file_exists($session->archivePath) ? filesize($session->archivePath) : 0; + // Verify archive integrity + $session->log('Verifying archive integrity...'); + $this->verifyArchive($session->archivePath, $session->backupType); + $session->log('Archive integrity verified'); + // MokoRestore wrapper if ($session->includeMokoRestore) { $session->log('Wrapping with MokoRestore script...'); @@ -449,6 +454,50 @@ class SteppedBackupEngine $this->completeRecord($session, $uploadFailed); } + /** + * Verify that a backup archive can be opened and contains expected entries. + * + * @param string $archivePath Absolute path to the archive file + * @param string $backupType Backup type: full, database, files, differential + * + * @throws \RuntimeException If the archive fails verification + */ + private function verifyArchive(string $archivePath, string $backupType): void + { + if (!is_file($archivePath)) { + throw new \RuntimeException('Archive file does not exist: ' . $archivePath); + } + + $zip = new \ZipArchive(); + + if ($zip->open($archivePath, \ZipArchive::RDONLY) !== true) { + throw new \RuntimeException('Archive integrity check failed: cannot open ZIP file'); + } + + if ($zip->numFiles < 1) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: archive contains no files'); + } + + // Verify database.sql exists when backup includes database + if ($backupType !== 'files') { + if ($zip->locateName('database.sql') === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: database.sql missing from archive'); + } + } + + // Spot-check: verify the first entry is readable + $firstName = $zip->getNameIndex(0); + + if ($firstName === false) { + $zip->close(); + throw new \RuntimeException('Archive integrity check failed: cannot read first entry'); + } + + $zip->close(); + } + /** * Mark the backup record as complete. */ diff --git a/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php b/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php new file mode 100644 index 0000000..9dbf58a --- /dev/null +++ b/source/packages/plg_console_mokosuitebackup/src/Command/SnapshotCommand.php @@ -0,0 +1,268 @@ + + * @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]; + } +} diff --git a/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php b/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php index ce6eae4..1556caf 100644 --- a/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php +++ b/source/packages/plg_console_mokosuitebackup/src/Extension/MokoSuiteBackupConsole.php @@ -20,6 +20,7 @@ use Joomla\Plugin\Console\MokoSuiteBackup\Command\ListCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\ProfilesCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\RestoreCommand; use Joomla\Plugin\Console\MokoSuiteBackup\Command\RunCommand; +use Joomla\Plugin\Console\MokoSuiteBackup\Command\SnapshotCommand; final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterface { @@ -41,5 +42,6 @@ final class MokoSuiteBackupConsole extends CMSPlugin implements SubscriberInterf $app->addCommand(new ProfilesCommand()); $app->addCommand(new RestoreCommand()); $app->addCommand(new CleanupCommand()); + $app->addCommand(new SnapshotCommand()); } } diff --git a/source/packages/plg_webservices_mokosuitebackup/src/Extension/MokoSuiteBackupWebServices.php b/source/packages/plg_webservices_mokosuitebackup/src/Extension/MokoSuiteBackupWebServices.php index b56c0d9..7538399 100644 --- a/source/packages/plg_webservices_mokosuitebackup/src/Extension/MokoSuiteBackupWebServices.php +++ b/source/packages/plg_webservices_mokosuitebackup/src/Extension/MokoSuiteBackupWebServices.php @@ -9,12 +9,19 @@ * * REST API endpoints — wire-compatible with the mcp_mokosuitebackup MCP server. * - * Akeeba-compatible routes: - * POST /api/index.php/v1/mokosuitebackup/backup — Start backup - * GET /api/index.php/v1/mokosuitebackup/backups — List records - * DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record - * GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive - * GET /api/index.php/v1/mokosuitebackup/profiles — List profiles + * Backup routes: + * POST /api/index.php/v1/mokosuitebackup/backup — Start backup + * GET /api/index.php/v1/mokosuitebackup/backups — List records + * DELETE /api/index.php/v1/mokosuitebackup/backup/:id — Delete record + * GET /api/index.php/v1/mokosuitebackup/backup/:id/download — Download archive + * GET /api/index.php/v1/mokosuitebackup/profiles — List profiles + * + * Snapshot routes: + * GET /api/index.php/v1/mokosuitebackup/snapshots — List snapshots + * POST /api/index.php/v1/mokosuitebackup/snapshot — Create snapshot + * POST /api/index.php/v1/mokosuitebackup/snapshot/:id/restore — Restore snapshot + * DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id — Delete snapshot + * GET /api/index.php/v1/mokosuitebackup/snapshot/:id/download — Download snapshot JSON */ namespace Joomla\Plugin\WebServices\MokoSuiteBackup\Extension; @@ -94,5 +101,62 @@ final class MokoSuiteBackupWebServices extends CMSPlugin implements SubscriberIn $defaults ) ); + + // --- Snapshot routes --- + + // List snapshots (GET) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokosuitebackup/snapshots', + 'snapshots.displayList', + [], + $defaults + ) + ); + + // Create a snapshot (POST) + $router->addRoute( + new Route( + ['POST'], + 'v1/mokosuitebackup/snapshot', + 'snapshots.create', + [], + $defaults + ) + ); + + // Restore a snapshot (POST) + $router->addRoute( + new Route( + ['POST'], + 'v1/mokosuitebackup/snapshot/:id/restore', + 'snapshots.restore', + ['id' => '(\d+)'], + $defaults + ) + ); + + // Delete a snapshot (DELETE) + $router->addRoute( + new Route( + ['DELETE'], + 'v1/mokosuitebackup/snapshot/:id', + 'snapshots.delete', + ['id' => '(\d+)'], + $defaults + ) + ); + + // Download a snapshot JSON file (GET) + $router->addRoute( + new Route( + ['GET'], + 'v1/mokosuitebackup/snapshot/:id/download', + 'snapshots.download', + ['id' => '(\d+)'], + $defaults + ) + ); } }