From c72e950a25966d7b950266c064b60c36eb70e1e0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 21 Jun 2026 19:46:07 -0500 Subject: [PATCH] feat: REST API endpoints for content snapshots (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add five endpoints matching the existing backup API pattern: - GET /snapshots — list with pagination - POST /snapshot — create (content_types, description) - POST /snapshot/:id/restore — restore (mode, content_types) - DELETE /snapshot/:id — delete record + file - GET /snapshot/:id/download — stream JSON file ACL: mokosuitebackup.snapshot.manage for write ops, core.manage for read. Routes registered in webservices plugin alongside backup routes. Closes #54 --- CHANGELOG.md | 5 + .../src/Controller/SnapshotsController.php | 307 ++++++++++++++++++ .../Extension/MokoSuiteBackupWebServices.php | 76 ++++- 3 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php 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/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 + ) + ); } }