Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php
T
Jonathan Miller c72e950a25
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 15s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 39s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m49s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
feat: REST API endpoints for content snapshots (#54)
Add five endpoints matching the existing backup API pattern:
- GET /snapshots — list with pagination
- POST /snapshot — create (content_types, description)
- POST /snapshot/:id/restore — restore (mode, content_types)
- DELETE /snapshot/:id — delete record + file
- GET /snapshot/:id/download — stream JSON file

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

Closes #54
2026-06-21 19:46:07 -05:00

308 lines
8.2 KiB
PHP

<?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;
}
}