diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fec5ae0..ac80c644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Snapshot transfer + master→slave injection (#237):** download a content snapshot as a portable **`.msbsnap`** file (zip of `manifest.json` + `snapshot.json` + a sha256 integrity check); **import** one on another site via a new Import button on the Snapshots page (materialises a local snapshot record you can then restore); and a **direct injection API** — `POST /api/index.php/v1/mokosuitebackup/snapshot/inject` — so a **master** site can push a snapshot straight into a **slave**'s Web Services API. Two conflict modes ship now: **overwrite** (replace matching items by ID — master wins) and **create** (skip existing, add only new), selectable per inject call or defaulted in **Options → Snapshot Transfer**. A receiving site must opt in via the new *Allow Snapshot Injection* setting. (The **duplicate** mode — insert as new copies with remapped IDs — is recognised but returns a clear "not yet available"; it's the next dedicated, test-worthy piece because it needs article/category/tag/field ID remapping.) - Full-screen backup now also fronts **extension updates and uninstalls** (Extensions → Update / Manage), not just core Joomla updates. Because `com_installer`'s `update.update` / `manage.remove` are POST actions (CSRF token + a checked `cid[]` list) that a server-side redirect can't cleanly resume, this is done client-side: the toolbar Update/Uninstall click is intercepted, the selection is captured, the full-screen backup runs, and on return the original selection is restored and the real POST form is re-submitted. Gated by `backup_before_update` / `backup_before_uninstall`, super-user only, and skipped if a backup already ran this session. - Manual **"Backup Now"** completion now offers a **View backup record** button (links straight to the record that was just created). It appears only for manual backups — the pre-update / pre-uninstall flow hands control back to Joomla instead. - **Full-screen backup screen** (`view=runbackup`) for both pre-update and manual backups, modelled on Akeeba's Backup-on-Update — replaces the earlier popup-modal approach. Clicking Joomla's **Install the update** now redirects (server-side, Akeeba-style) to a dedicated full-page backup screen that runs the stepped backup with a real progress bar and then **automatically continues the update** via a validated `returnurl` (flagged `is_backed_up=1` so the backup isn't repeated). Crucially, **no backup runs synchronously inside the update request** — which is what white-screened large sites. The dashboard **Backup Now** now opens the same full-screen screen instead of an inline modal. (#196) diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php index 0d77d965..f3aa0f3a 100644 --- a/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/api/src/Controller/SnapshotsController.php @@ -21,9 +21,11 @@ namespace Joomla\Component\MokoSuiteBackup\Api\Controller; defined('_JEXEC') or die; +use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\MVC\Controller\ApiController; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotPortable; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; class SnapshotsController extends ApiController @@ -159,11 +161,11 @@ class SnapshotsController extends ApiController $data = json_decode($this->input->json->getRaw(), true) ?: []; - $mode = $data['mode'] ?? 'replace'; + $mode = (string) ($data['mode'] ?? 'replace'); $contentTypes = $data['content_types'] ?? []; - // Enforce valid restore mode - if (!in_array($mode, ['replace', 'merge'], true)) { + // Accept the external mode names too; the engine normalises + validates. + if (!in_array($mode, ['replace', 'merge', 'overwrite', 'create', 'duplicate'], true)) { $mode = 'replace'; } @@ -183,6 +185,86 @@ class SnapshotsController extends ApiController return $this; } + /** + * Inject a snapshot payload directly into this site (master → slave). + * POST /api/index.php/v1/mokosuitebackup/snapshot/inject + * + * Body: { + * "snapshot": { version, content_types, tables }, // the payload (required) + * "mode": "overwrite" | "create" | "duplicate", // optional; defaults to the site's snapshot_inject_mode + * "content_types": [ ... ], // optional filter + * "description": "..." // optional + * } + */ + public function inject(): 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; + } + + $params = ComponentHelper::getParams('com_mokosuitebackup'); + + // The receiving (slave) site must explicitly allow injection. + if (!(int) $params->get('snapshot_allow_inject', 0)) { + $this->app->setHeader('status', 403); + echo json_encode(['errors' => [['title' => 'Snapshot injection is disabled on this site']]]); + $this->app->close(); + + return $this; + } + + $body = json_decode($this->input->json->getRaw(), true) ?: []; + $snapshot = $body['snapshot'] ?? null; + + if (!is_array($snapshot)) { + $this->app->setHeader('status', 400); + echo json_encode(['errors' => [['title' => 'Missing "snapshot" payload']]]); + $this->app->close(); + + return $this; + } + + // Explicit mode wins; otherwise the site default from Options. + $mode = (string) ($body['mode'] ?? $params->get('snapshot_inject_mode', 'overwrite')); + $contentTypes = $body['content_types'] ?? []; + $description = (string) ($body['description'] ?? 'Injected snapshot'); + + // Materialise a local record from the payload, then restore it. + $ingest = SnapshotPortable::ingestData($snapshot, $description); + + if (empty($ingest['success'])) { + $this->app->setHeader('status', 422); + echo json_encode(['errors' => [['title' => $ingest['message']]]]); + $this->app->close(); + + return $this; + } + + $engine = new SnapshotRestoreEngine(); + $result = $engine->restore((int) $ingest['id'], $mode, $contentTypes); + $result['snapshot_id'] = (int) $ingest['id']; + + if (!empty($ingest['warnings'])) { + $result['warnings'] = array_merge($result['warnings'] ?? [], $ingest['warnings']); + } + + 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']]], 'data' => $result]); + } + + $this->app->close(); + + return $this; + } + /** * Delete a snapshot record and its data file (DELETE /api/index.php/v1/mokosuitebackup/snapshot/:id) */ diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index 84db7fca..f0ce6bda 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -206,6 +206,31 @@ /> +
+