diff --git a/.gitignore b/.gitignore index 4881dc19..1f90377a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ secrets/ *.pid *.seed + # ============================================================ # OS / Editor / IDE cruft # ============================================================ @@ -45,6 +46,7 @@ Icon? .idea/ .settings/ .claude/ +.gemini/ .vscode/* !.vscode/tasks.json !.vscode/settings.json.example @@ -116,6 +118,8 @@ out/ *.map *.css.map *.js.map +*.min.css +*.min.js *.tsbuildinfo # ============================================================ @@ -151,7 +155,7 @@ package-lock.json # PHP / Composer tooling # ============================================================ vendor/ -!source/media/vendor/ +!src/media/vendor/ composer.lock *.phar codeception.phar @@ -177,7 +181,7 @@ __pycache__/ *.egg *.egg-info/ .installed.cfg -MANIFEST +/MANIFEST develop-eggs/ downloads/ eggs/ @@ -201,3 +205,9 @@ hypothesis/ profile.ps1 .mcp.json + +# ============================================================ +# Local wiki clone (not version controlled) +# ============================================================ +wiki/ +docs/ diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index eb67f8f7..23419628 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: MokoGitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.00.00 +# VERSION: 02.58.37 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index a461584f..edc76ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ ## [Unreleased] +### Fixed +- Pre-update backup on the **Joomla Update** page is now **seamless** (Akeeba-style): after the full-screen backup runs, the plugin auto-ticks Joomla's "I have taken a backup" checkbox and clicks Install, so the update continues automatically instead of stopping for a second manual click. +- Pre-update full-screen backup now actually fires on the **Joomla Update** page. The "Install the update" button posts to `option=com_joomlaupdate&layout=update` (Joomla's confirm/updating page) — carrying **only `layout=update`**, no `view`/`task` — so the previous match on `view=update`/`update.install` never triggered. The plugin now intercepts `layout=update` (as well as `view=update` and the legacy `update.install`) and returns the browser there flagged `is_backed_up=1`. + +### 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. A third **duplicate** mode inserts everything as brand-new records with **remapped IDs** (articles, categories, modules — plus their tags, custom-field values and featured flags) using Joomla's Table API so assets/UCM/nested-sets stay valid; each item is inserted defensively (a bad item is skipped and logged, never fatal). +- 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) +- Retention now prunes **remote** copies too: when a backup is pruned by age/count, its archive is deleted from every enabled remote destination (SFTP / FTP / S3 / Google Drive), not just the local copy. Each uploader gained an idempotent `delete()` method (already-absent file = success), and removal is best-effort — a failing destination is logged but never blocks local pruning. The shared standalone `restore.php` is intentionally left in place (every backup overwrites it, so newer backups still depend on it). (#229) + +### Changed +- Consolidated backup plumbing into shared helpers (#230): + - New `RemoteUploaderFactory` replaces the `createUploaderFromParams()` copy that was duplicated in `BackupEngine` and `SteppedBackupEngine`. + - `RetentionManager` is now the single retention authority — it takes the global `max_age_days`/`max_backups` fallback and gained `pruneOrphans()`; the system plugin's hourly cleanup delegates to it and its duplicate `deleteBackupRecord()` logic is removed. + - The backend controller, Web Services API controller, and legacy `cli/mokosuitebackup.php` now run backups through the shared `BackupRunner` (gaining the normalized complete/warning/fail status) instead of instantiating `BackupEngine` directly. + +### Fixed +- Package installer is now **honest about success**: the postflight no longer prints "installed successfully" / next-steps when the install actually failed or only partially completed. Joomla logs a failed child extension but still runs the package postflight, so the postflight now (a) verifies every bundled child declared in the manifest actually registered in `#__extensions` (matched by element + type, and folder/group for plugins) and (b) verifies the component's schema tables — derived dynamically from the installed `install.mysql.sql` — actually exist. If anything is missing it enqueues an error listing what's missing and stops before the success message. Fail-open: any manifest/DB/IO glitch is treated as "nothing missing" so a transient error never turns a good install into a false failure. Unconditional housekeeping (e.g. download-key restore) still runs regardless. +- Pre-update full-screen backup screen now actually triggers on **Joomla 6** (and 4/5). The redirect matched only the legacy `update.install` task, which Joomla 4/5/6 don't use — they server-side-redirect to the **updating page `view=update`**, which then extracts the downloaded package from JavaScript. The plugin now intercepts the `view=update` page **load** (the last point before any files change) and returns the browser there flagged `is_backed_up=1` so the extraction proceeds after the backup. (`update.finalise` is intentionally not intercepted — by then the files are already extracted.) +- Pre-update/uninstall backup no longer **white-screens** the update on large sites. The synchronous backup that runs inside the extension update/uninstall request now raises `max_execution_time`/`memory_limit` (and `ignore_user_abort`) like the web-cron path, so it can't exhaust the request's default limits mid-backup. (Core Joomla updates additionally get a full-screen backup screen — see below.) +- Archive names for **CLI/console-triggered backups** no longer come out as `joomla.invalid_…`. The `[HOST]` placeholder took `$_SERVER['HTTP_HOST']` verbatim, but Joomla's console fills that with the reserved sentinel host `joomla.invalid`; the resolver now treats that (like empty/`localhost`) as unusable and falls back to the configured `live_site` host, then the system hostname. (Set the site's *live_site* to get the exact domain in CLI-built names.) +- Standalone restore script generation no longer aborts backups with `str_replace() expects at least 3 arguments, 2 given`. `MokoRestore::generateStandaloneScript()` had a `str_replace()` call (the "Backup Archive" pre-check rewrite) that was missing its `$php` subject argument, so **every** standalone-mode backup fatally errored while "Generating standalone restore.php…" — the archive still finalized and uploaded, but no `restore.php` was ever produced (the true root cause behind #226). (#226) +- Remote upload: the standalone restore script upload is no longer silent — its result is now checked and logged, and a failed restore-script upload marks the backup as `warning` (previously the result was discarded, so a missing restore script on the remote went unreported while the archive still showed success). (#226) ## [02.58.00] --- 2026-07-06 diff --git a/SECURITY.md b/SECURITY.md index fdc86d1e..9b01abfe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,7 +23,7 @@ DEFGROUP: Template-Joomla INGROUP: Template-Joomla.Documentation REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Joomla PATH: /SECURITY.md -VERSION: 02.58.00 +VERSION: 02.58.37 BRIEF: Security vulnerability reporting and handling policy --> diff --git a/source/packages/MokoSuiteClient b/source/packages/MokoSuiteClient index 30a6b532..246f7bb1 160000 --- a/source/packages/MokoSuiteClient +++ b/source/packages/MokoSuiteClient @@ -1 +1 @@ -Subproject commit 30a6b53222d9d8980134aeb4cb51f29579d8f9f1 +Subproject commit 246f7bb10c2011ec667468302cff67d5882c4456 diff --git a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php index 2cc5ebe5..5bce4504 100644 --- a/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/api/src/Controller/BackupsController.php @@ -13,7 +13,7 @@ namespace Joomla\Component\MokoSuiteBackup\Api\Controller; defined('_JEXEC') or die; use Joomla\CMS\MVC\Controller\ApiController; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; class BackupsController extends ApiController { @@ -38,8 +38,7 @@ class BackupsController extends ApiController $profileId = (int) ($data['profile'] ?? 1); $description = $data['description'] ?? 'API backup ' . date('Y-m-d H:i:s'); - $engine = new BackupEngine(); - $result = $engine->run($profileId, $description, 'api'); + $result = (new BackupRunner())->run($profileId, $description, 'api'); if ($result['success']) { $this->app->setHeader('status', 200); 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/cli/mokosuitebackup.php b/source/packages/com_mokosuitebackup/cli/mokosuitebackup.php index 29f1c936..7e78076f 100644 --- a/source/packages/com_mokosuitebackup/cli/mokosuitebackup.php +++ b/source/packages/com_mokosuitebackup/cli/mokosuitebackup.php @@ -30,7 +30,7 @@ if (!defined('JPATH_BASE')) { require_once JPATH_BASE . '/includes/framework.php'; use Joomla\CMS\Factory; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; // Parse CLI arguments $profileId = 1; @@ -56,8 +56,7 @@ echo "Profile: {$profileId}\n"; echo "Description: {$description}\n"; echo "Starting backup...\n\n"; -$engine = new BackupEngine(); -$result = $engine->run($profileId, $description, 'cli'); +$result = (new BackupRunner())->run($profileId, $description, 'cli'); if ($result['success']) { echo "SUCCESS: " . $result['message'] . "\n"; 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 @@ /> +
+ + + + + + + + + +
+
MokoSuiteBackup - 02.58.00 + 02.58.37 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.01.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.01.sql new file mode 100644 index 00000000..726a3aa9 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.01.sql @@ -0,0 +1 @@ +/* 02.58.01 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.02.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.02.sql new file mode 100644 index 00000000..56b3dc21 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.02.sql @@ -0,0 +1 @@ +/* 02.58.02 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.03.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.03.sql new file mode 100644 index 00000000..1dd02ee1 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.03.sql @@ -0,0 +1 @@ +/* 02.58.03 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.04.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.04.sql new file mode 100644 index 00000000..6add05b7 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.04.sql @@ -0,0 +1 @@ +/* 02.58.04 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.05.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.05.sql new file mode 100644 index 00000000..228fb8d0 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.05.sql @@ -0,0 +1 @@ +/* 02.58.05 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.06.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.06.sql new file mode 100644 index 00000000..6f510638 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.06.sql @@ -0,0 +1 @@ +/* 02.58.06 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.07.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.07.sql new file mode 100644 index 00000000..0d01bce5 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.07.sql @@ -0,0 +1 @@ +/* 02.58.07 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.08.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.08.sql new file mode 100644 index 00000000..e3bd5427 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.08.sql @@ -0,0 +1 @@ +/* 02.58.08 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.09.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.09.sql new file mode 100644 index 00000000..7f58fde3 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.09.sql @@ -0,0 +1 @@ +/* 02.58.09 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.10.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.10.sql new file mode 100644 index 00000000..4aaacaa2 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.10.sql @@ -0,0 +1 @@ +/* 02.58.10 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.11.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.11.sql new file mode 100644 index 00000000..aec6c0bf --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.11.sql @@ -0,0 +1 @@ +/* 02.58.11 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.12.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.12.sql new file mode 100644 index 00000000..296d5bd0 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.12.sql @@ -0,0 +1 @@ +/* 02.58.12 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.13.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.13.sql new file mode 100644 index 00000000..0d546c54 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.13.sql @@ -0,0 +1 @@ +/* 02.58.13 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.14.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.14.sql new file mode 100644 index 00000000..6c3a5263 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.14.sql @@ -0,0 +1 @@ +/* 02.58.14 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.15.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.15.sql new file mode 100644 index 00000000..e0816695 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.15.sql @@ -0,0 +1 @@ +/* 02.58.15 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.16.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.16.sql new file mode 100644 index 00000000..70c3ae57 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.16.sql @@ -0,0 +1 @@ +/* 02.58.16 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.17.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.17.sql new file mode 100644 index 00000000..3cdb9169 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.17.sql @@ -0,0 +1 @@ +/* 02.58.17 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.19.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.19.sql new file mode 100644 index 00000000..ef0ad5d8 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.19.sql @@ -0,0 +1 @@ +/* 02.58.19 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.20.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.20.sql new file mode 100644 index 00000000..5fdbdc3b --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.20.sql @@ -0,0 +1 @@ +/* 02.58.20 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.21.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.21.sql new file mode 100644 index 00000000..407b0824 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.21.sql @@ -0,0 +1 @@ +/* 02.58.21 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.22.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.22.sql new file mode 100644 index 00000000..9f88f32c --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.22.sql @@ -0,0 +1 @@ +/* 02.58.22 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.23.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.23.sql new file mode 100644 index 00000000..d3fbf325 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.23.sql @@ -0,0 +1 @@ +/* 02.58.23 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.24.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.24.sql new file mode 100644 index 00000000..b60c1486 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.24.sql @@ -0,0 +1 @@ +/* 02.58.24 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.26.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.26.sql new file mode 100644 index 00000000..1cba432c --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.26.sql @@ -0,0 +1 @@ +/* 02.58.26 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.29.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.29.sql new file mode 100644 index 00000000..7cf8fed0 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.29.sql @@ -0,0 +1 @@ +/* 02.58.29 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.30.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.30.sql new file mode 100644 index 00000000..44f0b75a --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.30.sql @@ -0,0 +1 @@ +/* 02.58.30 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.31.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.31.sql new file mode 100644 index 00000000..9ceedb8f --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.31.sql @@ -0,0 +1 @@ +/* 02.58.31 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.32.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.32.sql new file mode 100644 index 00000000..43a65062 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.32.sql @@ -0,0 +1 @@ +/* 02.58.32 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.35.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.35.sql new file mode 100644 index 00000000..8b1e57cf --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.35.sql @@ -0,0 +1 @@ +/* 02.58.35 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.36.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.36.sql new file mode 100644 index 00000000..bd262657 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.36.sql @@ -0,0 +1 @@ +/* 02.58.36 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.37.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.37.sql new file mode 100644 index 00000000..822b3fe5 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/02.58.37.sql @@ -0,0 +1 @@ +/* 02.58.37 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php index 9d568ad7..0442b4bb 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/AjaxController.php @@ -84,6 +84,40 @@ class AjaxController extends BaseController $this->sendJson($result); } + /** + * Mark the pre-action backup as satisfied for this session. + * + * Called by the full-screen backup screen after a successful backup so the + * imminent server-side onExtensionBeforeUpdate / onExtensionBeforeUninstall + * backup is skipped — it arms the same 10-minute throttle keys the system + * plugin checks, preventing a duplicate backup when the update / uninstall + * then proceeds. Arms BOTH keys so a single ack covers whichever action the + * full-screen backup was fronting. + * POST: task=ajax.preupdateAck + */ + public function preupdateAck(): void + { + if (!Session::checkToken('get') && !Session::checkToken('post')) { + $this->sendJson(['error' => true, 'message' => 'Invalid token'], 403); + + return; + } + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) { + $this->sendJson(['error' => true, 'message' => 'Access denied'], 403); + + return; + } + + // Same keys + semantics as plg_system_mokosuitebackup::runPreActionBackup(). + $session = Factory::getSession(); + $now = time(); + $session->set('mokosuitebackup.preaction_backup_before_update', $now); + $session->set('mokosuitebackup.preaction_backup_before_uninstall', $now); + + $this->sendJson(['error' => false, 'message' => 'Pre-action backup acknowledged']); + } + /** * Cancel a backup record stuck in "running" status. * POST: task=ajax.cancelBackup&id=123 diff --git a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php index 59a725b5..31086528 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php @@ -16,8 +16,8 @@ use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; -use Joomla\Component\MokoSuiteBackup\Administrator\Engine\BackupEngine; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\RestoreEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Service\BackupRunner; class BackupsController extends AdminController { @@ -54,8 +54,7 @@ class BackupsController extends AdminController $profileId = $this->input->getInt('profile_id', 1); $description = $this->input->getString('description', ''); - $engine = new BackupEngine(); - $result = $engine->run($profileId, $description, 'backend'); + $result = (new BackupRunner())->run($profileId, $description, 'backend'); // Surface preflight warnings as Joomla messages if (!empty($result['warnings'])) { diff --git a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php index f72ff53d..0026a7fc 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/SnapshotsController.php @@ -18,6 +18,7 @@ use Joomla\CMS\MVC\Controller\AdminController; use Joomla\CMS\Router\Route; use Joomla\CMS\Session\Session; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotEngine; +use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotPortable; use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SnapshotRestoreEngine; class SnapshotsController extends AdminController @@ -29,6 +30,95 @@ class SnapshotsController extends AdminController return parent::getModel($name, $prefix, $config); } + /** + * Download a snapshot as a portable .msbsnap file (manifest + payload + checksum). + */ + public function downloadPortable(): void + { + $this->checkToken('get'); + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $id = $this->input->getInt('id', 0); + $item = $id ? $this->getModel('Snapshot')->getItem($id) : null; + + if (!$item || empty($item->id)) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_NOT_FOUND'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + try { + $file = SnapshotPortable::package($item); + } catch (\Throwable $e) { + $this->setMessage($e->getMessage(), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $name = SnapshotPortable::filename($item); + + while (@ob_end_clean()) { + // flush buffers + } + + header('Content-Type: application/octet-stream'); + header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($name)); + header('Content-Length: ' . filesize($file)); + header('Cache-Control: no-cache, must-revalidate'); + + readfile($file); + @unlink($file); + + $this->app->close(); + } + + /** + * Import an uploaded .msbsnap file — materialise a local snapshot record. + */ + public function import(): void + { + $this->checkToken(); + + if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) { + $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_ACCESS_FORBIDDEN'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $file = $this->input->files->get('msbsnap_file', null, 'raw'); + + if (!is_array($file) || empty($file['tmp_name']) || (int) ($file['error'] ?? 1) !== 0 + || !is_uploaded_file($file['tmp_name'])) { + $this->setMessage(Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_IMPORT_NO_FILE'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + + return; + } + + $result = SnapshotPortable::ingestZip($file['tmp_name']); + + if (!empty($result['success'])) { + foreach ($result['warnings'] ?? [] as $warning) { + $this->app->enqueueMessage($warning, 'warning'); + } + + $this->setMessage($result['message']); + } else { + $this->setMessage($result['message'], 'error'); + } + + $this->setRedirect(Route::_('index.php?option=com_mokosuitebackup&view=snapshots', false)); + } + /** * Create a new content snapshot. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index d9105d32..5fd38bfa 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -304,7 +304,18 @@ class BackupEngine $this->log(' Upload complete: ' . $result['message']); if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { - $uploader->upload($restoreScriptPath, basename($restoreScriptPath)); + $scriptName = basename($restoreScriptPath); + $this->log(' Uploading restore script: ' . $scriptName . '...'); + $scriptResult = $uploader->upload($restoreScriptPath, $scriptName); + + if (!empty($scriptResult['success'])) { + $this->log(' Restore script uploaded: ' . ($scriptResult['message'] ?? $scriptName)); + } else { + /* Never silent: a missing restore script makes the archive + much harder to restore, so surface it as a warning. */ + $uploadFailed = true; + $this->log(' WARNING: Restore script upload failed: ' . ($scriptResult['message'] ?? 'unknown error')); + } } } else { $uploadFailed = true; @@ -500,24 +511,7 @@ class BackupEngine */ private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface { - $prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_']; - $prefix = $prefixMap[$type] ?? ''; - - $prefixed = []; - - foreach ($params as $key => $value) { - $prefixed[$prefix . $key] = $value; - } - - $fake = (object) $prefixed; - - return match ($type) { - 'ftp' => new FtpUploader($fake), - 'sftp' => new SftpUploader($fake), - 'google_drive' => new GoogleDriveUploader($fake), - 's3' => new S3Uploader($fake), - default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), - }; + return RemoteUploaderFactory::create($type, $params); } /** diff --git a/source/packages/com_mokosuitebackup/src/Engine/FtpUploader.php b/source/packages/com_mokosuitebackup/src/Engine/FtpUploader.php index 465f0340..265c399c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/FtpUploader.php +++ b/source/packages/com_mokosuitebackup/src/Engine/FtpUploader.php @@ -89,6 +89,52 @@ class FtpUploader implements RemoteUploaderInterface } } + public function delete(string $remoteName): array + { + if (!extension_loaded('ftp')) { + return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP deletes. Enable it in php.ini.']; + } + + if (empty($this->host)) { + return ['success' => false, 'message' => 'FTP host is not configured']; + } + + $conn = $this->connect(); + + if (!$conn) { + return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port]; + } + + try { + if (!@ftp_login($conn, $this->username, $this->password)) { + throw new \RuntimeException('FTP login failed for user: ' . $this->username); + } + + if ($this->passive) { + ftp_pasv($conn, true); + } + + $remoteFile = $this->remotePath . '/' . $remoteName; + + /* Already-absent files: ftp_delete returns false, but if the file + isn't there the retention goal is already met, so probe size to + decide. ftp_size returns -1 when the file does not exist. */ + if (@ftp_size($conn, $remoteFile) < 0) { + return ['success' => true, 'message' => 'FTP: nothing to delete (' . $remoteFile . ')']; + } + + if (!@ftp_delete($conn, $remoteFile)) { + throw new \RuntimeException('ftp_delete failed for: ' . $remoteFile); + } + + return ['success' => true, 'message' => 'Deleted from FTP: ' . $remoteFile]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'FTP delete failed: ' . $e->getMessage()]; + } finally { + @ftp_close($conn); + } + } + public function testConnection(): array { if (empty($this->host)) { diff --git a/source/packages/com_mokosuitebackup/src/Engine/GoogleDriveUploader.php b/source/packages/com_mokosuitebackup/src/Engine/GoogleDriveUploader.php index 7d1d83d9..c57d1478 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/GoogleDriveUploader.php +++ b/source/packages/com_mokosuitebackup/src/Engine/GoogleDriveUploader.php @@ -81,6 +81,59 @@ class GoogleDriveUploader implements RemoteUploaderInterface } } + public function delete(string $remoteName): array + { + if (!extension_loaded('curl')) { + return ['success' => false, 'message' => 'PHP ext-curl is required for Google Drive deletes. Enable it in php.ini.']; + } + + if (empty($this->clientId) || empty($this->refreshToken)) { + return ['success' => false, 'message' => 'Google Drive credentials not configured']; + } + + try { + $accessToken = $this->getAccessToken(); + + /* Drive has no path addressing — resolve the file id(s) by name + within the configured folder, then delete each match. */ + $q = "name = '" . str_replace("'", "\\'", $remoteName) . "' and trashed = false"; + + if (!empty($this->folderId)) { + $q .= " and '" . $this->folderId . "' in parents"; + } + + $listUrl = self::API_URL . '/files?q=' . rawurlencode($q) . '&fields=' . rawurlencode('files(id,name)') . '&pageSize=25'; + $response = $this->curlRequest('GET', $listUrl, null, [ + 'Authorization: Bearer ' . $accessToken, + ]); + + if ($response['code'] !== 200) { + throw new \RuntimeException('Drive file lookup failed (HTTP ' . $response['code'] . ')'); + } + + $files = json_decode($response['body'], true)['files'] ?? []; + + if (empty($files)) { + return ['success' => true, 'message' => 'Google Drive: nothing to delete (' . $remoteName . ')']; + } + + foreach ($files as $file) { + $del = $this->curlRequest('DELETE', self::API_URL . '/files/' . rawurlencode($file['id']), null, [ + 'Authorization: Bearer ' . $accessToken, + ]); + + /* 204 = deleted, 404 = already gone — both acceptable. */ + if ($del['code'] !== 204 && $del['code'] !== 200 && $del['code'] !== 404) { + return ['success' => false, 'message' => 'Google Drive delete failed for ' . $remoteName . ' (HTTP ' . $del['code'] . ')']; + } + } + + return ['success' => true, 'message' => 'Deleted from Google Drive: ' . $remoteName]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Google Drive delete failed: ' . $e->getMessage()]; + } + } + public function testConnection(): array { if (empty($this->clientId) || empty($this->refreshToken)) { diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index eb6ea0aa..1d06801b 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -204,7 +204,8 @@ ORIG, 'ok' => $backupCount > 0, 'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']), ]; -REPL +REPL, + $php ); /* Modify remaining pre-checks to use getSelectedBackupFile() */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php b/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php index 2d6b5486..cccbb0aa 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php +++ b/source/packages/com_mokosuitebackup/src/Engine/PlaceholderResolver.php @@ -52,15 +52,25 @@ class PlaceholderResolver { $now = new \DateTimeImmutable('now'); - /* Resolve hostname: prefer HTTP_HOST (web), then try Joomla config (CLI), then system hostname */ - $rawHost = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''; + /* Resolve hostname: prefer the real request host (web), then the + configured live_site (CLI), then the system hostname. Joomla's console + fills the request host with the placeholder 'joomla.invalid' (an + RFC 6761 reserved TLD used as a CLI sentinel), so treat that — along + with empty/localhost — as unusable and fall through; otherwise every + CLI-triggered backup would be named 'joomla.invalid_…'. */ + $unusable = static function (string $h): bool { + $h = strtolower(trim($h)); - if (empty($rawHost) || $rawHost === 'localhost') { + return $h === '' || $h === 'localhost' || $h === 'joomla.invalid'; + }; + + $rawHost = (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? ''); + + if ($unusable($rawHost)) { try { - $app = Factory::getApplication(); - $liveSite = $app->get('live_site', ''); + $liveSite = (string) Factory::getApplication()->get('live_site', ''); - if (!empty($liveSite)) { + if ($liveSite !== '') { $parsed = parse_url($liveSite, PHP_URL_HOST); if (!empty($parsed)) { @@ -68,12 +78,13 @@ class PlaceholderResolver } } } catch (\Throwable $e) { - /* fallback */ + /* fall through to system hostname */ } } - if (empty($rawHost)) { - $rawHost = php_uname('n'); + if ($unusable($rawHost)) { + $sysHost = php_uname('n'); + $rawHost = $sysHost !== '' ? $sysHost : 'site'; } $hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $rawHost); diff --git a/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderFactory.php b/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderFactory.php new file mode 100644 index 00000000..84e39c71 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderFactory.php @@ -0,0 +1,69 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +/** + * Builds a remote uploader from a `#__mokosuitebackup_remotes` row. + * + * This was previously a private method duplicated in both BackupEngine and + * SteppedBackupEngine (and needed a third time by RetentionManager for remote + * deletion). It is centralised here so the type→class map and the param + * key-prefixing convention live in exactly one place. + * + * The remotes table stores per-type settings as a flat JSON object with + * un-prefixed keys (e.g. `{"host":"…","path":"/backups"}`). The concrete + * uploaders, however, read prefixed keys off a profile-shaped object + * (e.g. `sftp_host`, `s3_bucket`). This factory bridges the two. + */ +final class RemoteUploaderFactory +{ + /** + * Map of remote type → the key prefix its uploader expects. + */ + private const PREFIX_MAP = [ + 'ftp' => 'ftp_', + 'sftp' => 'sftp_', + 's3' => 's3_', + 'google_drive' => 'gdrive_', + ]; + + /** + * Create an uploader for the given remote type from decoded JSON params. + * + * @param string $type Remote type: ftp, sftp, s3, google_drive + * @param array $params Key-value params decoded from the remote's JSON + * + * @return RemoteUploaderInterface + * + * @throws \InvalidArgumentException On an unknown remote type + */ + public static function create(string $type, array $params): RemoteUploaderInterface + { + $prefix = self::PREFIX_MAP[$type] ?? ''; + $prefixed = []; + + foreach ($params as $key => $value) { + $prefixed[$prefix . $key] = $value; + } + + $fake = (object) $prefixed; + + return match ($type) { + 'ftp' => new FtpUploader($fake), + 'sftp' => new SftpUploader($fake), + 'google_drive' => new GoogleDriveUploader($fake), + 's3' => new S3Uploader($fake), + default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), + }; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderInterface.php b/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderInterface.php index fdc67a55..9b64956e 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderInterface.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RemoteUploaderInterface.php @@ -24,6 +24,21 @@ interface RemoteUploaderInterface */ public function upload(string $localPath, string $remoteName): array; + /** + * Delete a previously-uploaded file from remote storage. + * + * Used by retention pruning so that expiring a local backup also removes + * its archive from every remote destination. Implementations should treat + * an already-absent remote file as success (idempotent delete), so a + * partially-cleaned destination never blocks pruning. + * + * @param string $remoteName Filename on the remote end (the same + * $remoteName that was passed to upload()) + * + * @return array{success: bool, message: string} + */ + public function delete(string $remoteName): array; + /** * Test the connection / credentials without uploading. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php index 8a611ba9..d3f1ee2a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php +++ b/source/packages/com_mokosuitebackup/src/Engine/RetentionManager.php @@ -15,32 +15,54 @@ defined('_JEXEC') or die; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; /** - * Enforces per-profile backup retention. + * Enforces per-profile backup retention — the single retention authority. * * A profile may cap retained backups by age (retention_days) and/or by - * number of copies (retention_count). A backup is pruned when EITHER rule - * matches: it is older than retention_days OR it falls outside the newest - * retention_count copies. Deleting a record also removes its archive and - * log file, mirroring the Backup table's delete(). + * number of copies (retention_count). When a profile leaves a rule at 0 the + * caller-supplied global default is used instead. A backup is pruned when + * EITHER rule matches: it is older than the effective days OR it falls outside + * the newest effective count of copies. + * + * Pruning a record removes, in this order: + * 1. the DB row (#__mokosuitebackup_records), + * 2. the local archive + its .log file, and + * 3. the archive copy on every enabled remote destination for the profile. + * + * The standalone restore script (restore.php) is intentionally NOT deleted + * from remotes: it is a single, fixed-name file that every backup overwrites, + * so newer backups still depend on it — removing it while pruning an old + * archive would break restore for the copies that remain. + * + * Because records do not store which destinations they were uploaded to, + * remote deletion targets the profile's *currently enabled* remotes. Remote + * deletes are idempotent (an already-absent file counts as success) and + * best-effort: a failing remote is logged but never blocks local pruning. */ final class RetentionManager { /** * Prune old backups for a profile according to its retention settings. * - * Called after a backup completes. Only 'complete' and 'warning' records - * are considered — pending/running/failed records are never pruned here. + * Called after a backup completes and from the periodic admin-side cleanup. + * Only 'complete' and 'warning' records are considered — pending/running/ + * failed records are never pruned here. * - * @param object $db Database driver - * @param object $profile Profile row (needs id, retention_days, retention_count) + * @param object $db Database driver + * @param object $profile Profile row (needs id, retention_days, retention_count) + * @param int $globalDays Fallback max age (days) when the profile's is 0 + * @param int $globalCount Fallback max copies when the profile's is 0 * * @return int Number of backup records deleted */ - public static function prune(object $db, object $profile): int + public static function prune(object $db, object $profile, int $globalDays = 0, int $globalCount = 0): int { $days = (int) ($profile->retention_days ?? 0); $count = (int) ($profile->retention_count ?? 0); + // A profile value of 0 means "use the global default". + $days = $days > 0 ? $days : $globalDays; + $count = $count > 0 ? $count : $globalCount; + // No retention configured — nothing to do. if ($days <= 0 && $count <= 0) { return 0; @@ -48,7 +70,7 @@ final class RetentionManager // Newest first, so the index is the copy's position from the top. $query = $db->getQuery(true) - ->select($db->quoteName(['id', 'absolute_path', 'backupstart'])) + ->select($db->quoteName(['id', 'archivename', 'absolute_path', 'backupstart'])) ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')') @@ -61,6 +83,7 @@ final class RetentionManager } $cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null; + $remotes = self::loadEnabledRemotes($db, (int) $profile->id); $deleted = 0; foreach ($records as $index => $record) { @@ -72,7 +95,7 @@ final class RetentionManager continue; } - if (self::deleteRecord($db, $record)) { + if (self::deleteRecord($db, $record, $remotes)) { $deleted++; } } @@ -81,12 +104,54 @@ final class RetentionManager } /** - * Delete a single backup record and its on-disk archive + log file. + * Delete backup records whose profile no longer exists. * - * The DB row is removed first; the files are only unlinked if that - * succeeds, so a failed delete never orphans the record from its files. + * Orphans have no owning profile and therefore no remote destinations to + * reconcile, so only the local archive/log and DB row are removed. + * + * @param object $db Database driver + * + * @return int Number of orphaned records deleted */ - private static function deleteRecord(object $db, object $record): bool + public static function pruneOrphans(object $db): int + { + $query = $db->getQuery(true) + ->select($db->quoteName(['r.id', 'r.archivename', 'r.absolute_path'], ['id', 'archivename', 'absolute_path'])) + ->from($db->quoteName('#__mokosuitebackup_records', 'r')) + ->join( + 'LEFT', + $db->quoteName('#__mokosuitebackup_profiles', 'p') + . ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id') + ) + ->where($db->quoteName('p.id') . ' IS NULL') + ->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')'); + $db->setQuery($query); + $orphans = $db->loadObjectList() ?: []; + + $deleted = 0; + + foreach ($orphans as $record) { + if (self::deleteRecord($db, $record, [])) { + $deleted++; + } + } + + return $deleted; + } + + /** + * Delete a single backup record, its on-disk archive + log file, and the + * archive copy on each enabled remote destination. + * + * The DB row is removed first; the local files are only unlinked if that + * succeeds, so a failed delete never orphans the record from its files. + * Remote deletion is best-effort and runs regardless. + * + * @param object $db Database driver + * @param object $record Record row (needs id, archivename, absolute_path) + * @param object[] $remotes Enabled remote rows (type, params) for the profile + */ + private static function deleteRecord(object $db, object $record, array $remotes): bool { $query = $db->getQuery(true) ->delete($db->quoteName('#__mokosuitebackup_records')) @@ -113,6 +178,69 @@ final class RetentionManager } } + self::deleteFromRemotes($record, $remotes); + return true; } + + /** + * Remove the record's archive from every enabled remote destination. + * + * Best-effort: each remote is tried independently and failures are logged, + * never thrown, so one unreachable destination can't stall retention. + * + * @param object $record Record row (needs archivename) + * @param object[] $remotes Enabled remote rows (type, params) + */ + private static function deleteFromRemotes(object $record, array $remotes): void + { + $archiveName = (string) ($record->archivename ?? ''); + + if ($archiveName === '' || empty($remotes)) { + return; + } + + foreach ($remotes as $remote) { + try { + $params = json_decode((string) ($remote->params ?? ''), true) ?: []; + $uploader = RemoteUploaderFactory::create((string) $remote->type, $params); + $result = $uploader->delete($archiveName); + + if (empty($result['success'])) { + error_log( + 'MokoSuiteBackup: retention could not delete ' . $archiveName + . ' from ' . $remote->type . ' remote: ' . ($result['message'] ?? 'unknown error') + ); + } + } catch (\Throwable $e) { + error_log( + 'MokoSuiteBackup: retention remote-delete error for ' . $archiveName + . ' (' . ($remote->type ?? '?') . '): ' . $e->getMessage() + ); + } + } + } + + /** + * Load the profile's enabled remote destinations (type + params only). + * + * Returns an empty array if the remotes table is missing (very old installs) + * or the profile has none, so callers can iterate unconditionally. + */ + private static function loadEnabledRemotes(object $db, int $profileId): array + { + try { + $query = $db->getQuery(true) + ->select($db->quoteName(['type', 'params'])) + ->from($db->quoteName('#__mokosuitebackup_remotes')) + ->where($db->quoteName('profile_id') . ' = ' . $profileId) + ->where($db->quoteName('enabled') . ' = 1') + ->order($db->quoteName('ordering') . ' ASC'); + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } catch (\Throwable $e) { + return []; + } + } } diff --git a/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php b/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php index 5c70a2c5..cea187e7 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php +++ b/source/packages/com_mokosuitebackup/src/Engine/S3Uploader.php @@ -75,6 +75,54 @@ class S3Uploader implements RemoteUploaderInterface } } + public function delete(string $remoteName): array + { + if (!extension_loaded('curl')) { + return ['success' => false, 'message' => 'PHP ext-curl is required for S3 deletes. Enable it in php.ini.']; + } + + if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) { + return ['success' => false, 'message' => 'S3 credentials or bucket not configured']; + } + + try { + $objectKey = ($this->path ? $this->path . '/' : '') . $remoteName; + $url = $this->getObjectUrl($objectKey); + $headers = $this->signRequest('DELETE', $url, hash('sha256', '')); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 60, + ]); + + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + + throw new \RuntimeException('cURL error: ' . $error); + } + + curl_close($ch); + + /* S3 DELETE returns 204 on success and — by design — also 204 when + the key never existed. 404 is treated as already-gone too. */ + if ($code === 204 || $code === 200 || $code === 404) { + return ['success' => true, 'message' => 'Deleted from S3: s3://' . $this->bucket . '/' . $objectKey]; + } + + return ['success' => false, 'message' => 'S3 DELETE failed (HTTP ' . $code . '): ' . substr((string) $response, 0, 300)]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'S3 delete failed: ' . $e->getMessage()]; + } + } + public function testConnection(): array { if (empty($this->accessKey) || empty($this->secretKey) || empty($this->bucket)) { diff --git a/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php index d32f4c64..f72e8960 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SftpUploader.php @@ -100,6 +100,40 @@ class SftpUploader implements RemoteUploaderInterface } } + public function delete(string $remoteName): array + { + if (empty($this->host) || empty($this->username)) { + return ['success' => false, 'message' => 'SFTP is not configured']; + } + + $keyFile = null; + + try { + if (!empty($this->keyData)) { + $keyFile = $this->writeTempKey(); + } + + /* rm -f exits 0 even when the file is already gone, so an + already-deleted remote archive is treated as success. */ + $remoteFile = $this->remotePath . '/' . $remoteName; + $cmd = $this->buildSshCommand('rm -f ' . escapeshellarg($remoteFile), $keyFile); + + $output = []; + $exitCode = 0; + exec($cmd . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + return ['success' => false, 'message' => 'SFTP delete failed: ' . implode(' ', $output)]; + } + + return ['success' => true, 'message' => 'Deleted from SFTP: ' . $remoteFile]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'SFTP delete failed: ' . $e->getMessage()]; + } finally { + $this->cleanupTempKey($keyFile); + } + } + public function testConnection(): array { if (empty($this->host)) { diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php new file mode 100644 index 00000000..97627d22 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotPortable.php @@ -0,0 +1,285 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Portable snapshot transfer format (.msbsnap). + * + * A .msbsnap is a zip containing: + * - manifest.json — format/version, generator, source (host/Joomla/prefix), + * content summary, and a sha256 of the payload. + * - snapshot.json — the snapshot's content data (verbatim, same shape the + * snapshot engine writes: {version, content_types, tables}). + * + * package() builds a .msbsnap for an existing snapshot record (for download). + * ingestZip() validates an uploaded .msbsnap and materialises a local snapshot + * record from it (so the normal restore UX can act on it). + * ingestData() materialises a record from an already-parsed data array (used by + * the master→slave injection API, which receives the payload inline). + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Uri\Uri; +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +final class SnapshotPortable +{ + public const FORMAT = 'msbsnap'; + public const FORMAT_VERSION = 1; + public const MANIFEST_NAME = 'manifest.json'; + public const PAYLOAD_NAME = 'snapshot.json'; + + /** + * Build a portable .msbsnap zip for a snapshot record. + * + * @param object $snapshot Row from #__mokosuitebackup_snapshots (needs + * data_file, description, content_types, *_count) + * + * @return string Absolute path to a temp .msbsnap file (caller unlinks it) + * + * @throws \RuntimeException + */ + public static function package(object $snapshot): string + { + $dataFile = (string) ($snapshot->data_file ?? ''); + + if ($dataFile === '' || !is_file($dataFile) || !is_readable($dataFile)) { + throw new \RuntimeException('Snapshot data file is missing or unreadable.'); + } + + $payload = file_get_contents($dataFile); + + if ($payload === false) { + throw new \RuntimeException('Could not read snapshot data file.'); + } + + $manifest = [ + 'format' => self::FORMAT, + 'format_version' => self::FORMAT_VERSION, + 'generator' => 'MokoSuiteBackup', + 'created' => Factory::getDate()->toISO8601(), + 'source' => [ + 'host' => self::siteHost(), + 'joomla_version' => JVERSION, + 'db_prefix' => Factory::getDbo()->getPrefix(), + ], + 'snapshot' => [ + 'description' => (string) ($snapshot->description ?? ''), + 'content_types' => json_decode((string) ($snapshot->content_types ?? '[]'), true) ?: [], + 'articles_count' => (int) ($snapshot->articles_count ?? 0), + 'categories_count' => (int) ($snapshot->categories_count ?? 0), + 'modules_count' => (int) ($snapshot->modules_count ?? 0), + 'created' => (string) ($snapshot->created ?? ''), + ], + 'payload' => [ + 'file' => self::PAYLOAD_NAME, + 'sha256' => hash('sha256', $payload), + 'size' => strlen($payload), + ], + ]; + + $tmp = tempnam(sys_get_temp_dir(), 'msbsnap_'); + + if ($tmp === false) { + throw new \RuntimeException('Could not create a temporary file.'); + } + + $zip = new \ZipArchive(); + + if ($zip->open($tmp, \ZipArchive::OVERWRITE) !== true) { + @unlink($tmp); + + throw new \RuntimeException('Could not create the .msbsnap archive.'); + } + + $zip->addFromString(self::MANIFEST_NAME, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $zip->addFromString(self::PAYLOAD_NAME, $payload); + $zip->close(); + + return $tmp; + } + + /** + * A friendly download filename for a snapshot's portable file. + */ + public static function filename(object $snapshot): string + { + $id = (int) ($snapshot->id ?? 0); + $date = date('Ymd_His'); + + return 'snapshot-' . $id . '-' . $date . '.msbsnap'; + } + + /** + * Ingest an uploaded .msbsnap file: validate it and materialise a local + * snapshot record so the normal restore UX can act on it. + * + * @return array{success:bool,message:string,id?:int,warnings?:array} + */ + public static function ingestZip(string $zipPath, string $descriptionPrefix = 'Imported'): array + { + if (!is_file($zipPath) || !is_readable($zipPath)) { + return ['success' => false, 'message' => 'Uploaded file is missing or unreadable.']; + } + + $zip = new \ZipArchive(); + + if ($zip->open($zipPath) !== true) { + return ['success' => false, 'message' => 'Not a valid .msbsnap archive.']; + } + + try { + $manifestRaw = $zip->getFromName(self::MANIFEST_NAME); + $payloadRaw = $zip->getFromName(self::PAYLOAD_NAME); + } finally { + $zip->close(); + } + + if ($manifestRaw === false || $payloadRaw === false) { + return ['success' => false, 'message' => 'Archive is missing its manifest or payload.']; + } + + $manifest = json_decode($manifestRaw, true); + + if (!is_array($manifest) || ($manifest['format'] ?? '') !== self::FORMAT) { + return ['success' => false, 'message' => 'Unrecognised snapshot file format.']; + } + + if ((int) ($manifest['format_version'] ?? 0) > self::FORMAT_VERSION) { + return ['success' => false, 'message' => 'This snapshot file was made by a newer version of MokoSuiteBackup.']; + } + + $expected = (string) ($manifest['payload']['sha256'] ?? ''); + + if ($expected !== '' && !hash_equals($expected, hash('sha256', $payloadRaw))) { + return ['success' => false, 'message' => 'Snapshot payload failed its integrity (checksum) check.']; + } + + $data = json_decode($payloadRaw, true); + + if (!self::isValidData($data)) { + return ['success' => false, 'message' => 'Snapshot payload is not valid content data.']; + } + + $warnings = self::compatWarnings($manifest); + $srcHost = (string) ($manifest['source']['host'] ?? ''); + $srcDesc = (string) ($manifest['snapshot']['description'] ?? ''); + $descr = trim($descriptionPrefix . ': ' . ($srcDesc !== '' ? $srcDesc : 'snapshot') + . ($srcHost !== '' ? ' (from ' . $srcHost . ')' : '')); + + $result = self::ingestData($data, $descr); + + if (!empty($warnings) && !empty($result['success'])) { + $result['warnings'] = array_merge($result['warnings'] ?? [], $warnings); + } + + return $result; + } + + /** + * Materialise a snapshot record from an already-parsed data array. + * Used by the injection API (payload arrives inline, not as a zip). + * + * @return array{success:bool,message:string,id?:int,warnings?:array} + */ + public static function ingestData(array $data, string $description): array + { + if (!self::isValidData($data)) { + return ['success' => false, 'message' => 'Snapshot data is invalid or empty.']; + } + + try { + $backupDir = BackupDirectory::getDefaultAbsolute(); + BackupDirectory::ensureReady($backupDir); + + $filename = 'snapshot_' . date('Ymd_His') . '_imported.json'; + $filePath = $backupDir . '/' . $filename; + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + if ($json === false || file_put_contents($filePath, $json) === false) { + return ['success' => false, 'message' => 'Could not write the imported snapshot file.']; + } + + $tables = $data['tables'] ?? []; + $db = Factory::getDbo(); + + $record = (object) [ + 'description' => $description !== '' ? $description : 'Imported snapshot', + 'content_types' => json_encode(array_values($data['content_types'] ?? [])), + 'status' => 'complete', + 'articles_count' => count($tables['#__content'] ?? []), + 'categories_count' => count($tables['#__categories'] ?? []), + 'modules_count' => count($tables['#__modules'] ?? []), + 'data_file' => $filePath, + 'data_size' => strlen($json), + 'log' => 'Imported ' . Factory::getDate()->toSql(), + 'created' => Factory::getDate()->toSql(), + 'created_by' => (int) (Factory::getApplication()->getIdentity()->id ?? 0), + ]; + + $db->insertObject('#__mokosuitebackup_snapshots', $record, 'id'); + + return [ + 'success' => true, + 'message' => 'Snapshot imported (record #' . (int) $record->id . ').', + 'id' => (int) $record->id, + 'warnings' => [], + ]; + } catch (\Throwable $e) { + return ['success' => false, 'message' => 'Import failed: ' . $e->getMessage()]; + } + } + + /** + * Structural check for a snapshot data payload. + */ + private static function isValidData($data): bool + { + return is_array($data) + && isset($data['tables']) && is_array($data['tables']) + && isset($data['content_types']) && is_array($data['content_types']); + } + + /** + * Best-effort compatibility warnings (non-blocking). + * + * @return string[] + */ + private static function compatWarnings(array $manifest): array + { + $warnings = []; + $srcJoomla = (string) ($manifest['source']['joomla_version'] ?? ''); + $srcMajor = $srcJoomla !== '' ? (int) explode('.', $srcJoomla)[0] : 0; + $thisMajor = (int) explode('.', JVERSION)[0]; + + if ($srcMajor > 0 && $srcMajor !== $thisMajor) { + $warnings[] = 'Snapshot was created on Joomla ' . $srcJoomla . ' but this site is Joomla ' . JVERSION + . ' — content tables may differ; review after restoring.'; + } + + return $warnings; + } + + /** + * The current site's host, for the manifest (best-effort, informational). + */ + private static function siteHost(): string + { + try { + $host = Uri::getInstance(Uri::root())->getHost(); + } catch (\Throwable $e) { + $host = ''; + } + + return $host !== '' ? $host : (string) (Factory::getApplication()->get('sitename', 'site')); + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php index 0bf3c300..3b59ccf0 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SnapshotRestoreEngine.php @@ -59,7 +59,9 @@ class SnapshotRestoreEngine $this->log('WARNING: Could not increase memory limit to 512M'); } - if (!in_array($mode, ['replace', 'merge'])) { + $mode = $this->normaliseMode($mode); + + if (!in_array($mode, ['replace', 'merge', 'create', 'duplicate'], true)) { return ['success' => false, 'message' => 'Invalid restore mode: ' . $mode]; } @@ -123,12 +125,27 @@ class SnapshotRestoreEngine $prefix = $db->getPrefix(); $totalRows = 0; + // Build list of tables to restore based on selected types + $tablesToRestore = $this->getTablesToRestore($restoreTypes); + + /* Duplicate mode inserts brand-new records through Joomla's Table API, + whose Nested tables (categories, tags) issue LOCK TABLES — which + implicitly COMMITS any open transaction in MySQL/InnoDB. Wrapping it + in transactionStart()/Rollback() would be a false atomicity promise: + the first category/tag store would commit and defeat the rollback. + Duplicate mode is therefore best-effort and per-item defensive (a bad + item is skipped and logged, never fatal) rather than all-or-nothing. + The raw-DB modes (replace/create/merge) remain fully transactional. */ + $useTransaction = ($mode !== 'duplicate'); + try { - $db->transactionStart(); - - // Build list of tables to restore based on selected types - $tablesToRestore = $this->getTablesToRestore($restoreTypes); + if ($useTransaction) { + $db->transactionStart(); + } + if ($mode === 'duplicate') { + $totalRows = $this->restoreDuplicate($data, $restoreTypes); + } else { foreach ($tablesToRestore as $abstractTable) { if (!isset($data['tables'][$abstractTable])) { $this->log(' Skipping ' . $abstractTable . ' (not in snapshot)'); @@ -140,6 +157,8 @@ class SnapshotRestoreEngine if ($mode === 'replace') { $rowCount = $this->restoreReplace($db, $realTable, $abstractTable, $rows); + } elseif ($mode === 'create') { + $rowCount = $this->restoreCreate($db, $realTable, $abstractTable, $rows); } else { $rowCount = $this->restoreMerge($db, $realTable, $abstractTable, $rows); } @@ -147,8 +166,11 @@ class SnapshotRestoreEngine $totalRows += $rowCount; $this->log(' ' . $abstractTable . ': ' . $rowCount . ' rows restored'); } + } - $db->transactionCommit(); + if ($useTransaction) { + $db->transactionCommit(); + } $this->log('Restore complete: ' . $totalRows . ' total rows'); @@ -180,11 +202,15 @@ class SnapshotRestoreEngine 'log' => implode("\n", $this->log), ]; } catch (\Throwable $e) { - try { - $db->transactionRollback(); - $this->log('Transaction rolled back'); - } catch (\Exception $rollbackEx) { - $this->log('Rollback failed: ' . $rollbackEx->getMessage()); + if ($useTransaction) { + try { + $db->transactionRollback(); + $this->log('Transaction rolled back'); + } catch (\Exception $rollbackEx) { + $this->log('Rollback failed: ' . $rollbackEx->getMessage()); + } + } else { + $this->log('Duplicate mode is not transactional; records already inserted are left in place.'); } $this->log('FATAL: ' . $e->getMessage()); @@ -264,6 +290,340 @@ class SnapshotRestoreEngine return $count; } + /** + * Create mode: insert only rows that don't already exist; never overwrite. + * Non-destructive — existing content on the target is left untouched. Used + * by master→slave injection when the master must not clobber local edits. + */ + private function restoreCreate(object $db, string $realTable, string $abstractTable, array $rows): int + { + $pk = self::PRIMARY_KEYS[$abstractTable] ?? null; + $count = 0; + + foreach ($rows as $row) { + $obj = (object) $row; + + if ($pk !== null && isset($row[$pk])) { + $exists = $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName($realTable)) + ->where($db->quoteName($pk) . ' = ' . $db->quote($row[$pk])) + )->loadResult(); + + if ($exists) { + continue; + } + + $db->insertObject($realTable, $obj); + } else { + // Composite-key tables: insert, skip genuine duplicates. + try { + $db->insertObject($realTable, $obj); + } catch (\Exception $e) { + if (str_contains($e->getMessage(), 'Duplicate entry') || $e->getCode() === 1062) { + continue; + } + + throw $e; + } + } + + $count++; + } + + return $count; + } + + /** + * Normalise external mode names to the engine's internal modes. + * overwrite→replace, skip/new→create, copy→duplicate; passthrough otherwise. + */ + private function normaliseMode(string $mode): string + { + $map = [ + 'overwrite' => 'replace', + 'replace' => 'replace', + 'update' => 'merge', + 'merge' => 'merge', + 'skip' => 'create', + 'new' => 'create', + 'create' => 'create', + 'copy' => 'duplicate', + 'duplicate' => 'duplicate', + ]; + + return $map[strtolower(trim($mode))] ?? strtolower(trim($mode)); + } + + /** + * Duplicate mode: insert snapshot content as BRAND-NEW records (new IDs), + * leaving existing content untouched. Uses Joomla's Table API so assets, + * UCM entries, category/tag nested-sets and alias uniqueness are handled by + * core. Every item is wrapped in try/catch — a bad item is skipped + logged, + * never fatal — so a partial import degrades gracefully rather than corrupting. + * + * Known limits: workflow stage is left to the article table's default; + * references to content NOT included in the snapshot keep their original IDs. + * + * @return int Number of new records created + */ + private function restoreDuplicate(array $data, array $types): int + { + $app = Factory::getApplication(); + $db = Factory::getDbo(); + $tables = $data['tables'] ?? []; + $count = 0; + + $catMap = []; + $articleMap = []; + $moduleMap = []; + + // --- Categories (parents before children so the remap resolves) --- + if (in_array('categories', $types, true) && !empty($tables['#__categories'])) { + $factory = $app->bootComponent('com_categories')->getMVCFactory(); + $cats = $tables['#__categories']; + usort($cats, static fn ($a, $b) => ((int) ($a['level'] ?? 0)) <=> ((int) ($b['level'] ?? 0))); + + foreach ($cats as $row) { + $oldId = (int) ($row['id'] ?? 0); + $oldParent = (int) ($row['parent_id'] ?? 1); + $newParent = $catMap[$oldParent] ?? ($oldParent > 1 ? $oldParent : 1); + + unset($row['id'], $row['asset_id'], $row['lft'], $row['rgt'], $row['level'], $row['path']); + $row['extension'] = 'com_content'; + $row['parent_id'] = $newParent; + $row['alias'] = $this->uniqueAlias($db, '#__categories', (string) ($row['alias'] ?? ''), ['extension' => 'com_content']); + + try { + $table = $factory->createTable('Category', 'Administrator'); + $table->setLocation($newParent, 'last-child'); + + if ($table->bind($row) && $table->check() && $table->store()) { + $catMap[$oldId] = (int) $table->id; + $count++; + } else { + $this->log(' Category skipped: ' . $table->getError()); + } + } catch (\Throwable $e) { + $this->log(' Category "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); + } + } + } + + // --- Articles --- + if (in_array('articles', $types, true) && !empty($tables['#__content'])) { + $factory = $app->bootComponent('com_content')->getMVCFactory(); + $featured = []; + + foreach ($tables['#__content_frontpage'] ?? [] as $fp) { + $featured[(int) ($fp['content_id'] ?? 0)] = true; + } + + $tagTitles = $this->buildArticleTagTitles($tables); + + foreach ($tables['#__content'] as $row) { + $oldId = (int) ($row['id'] ?? 0); + $oldCat = (int) ($row['catid'] ?? 0); + + unset($row['id'], $row['asset_id']); + + if (isset($catMap[$oldCat])) { + $row['catid'] = $catMap[$oldCat]; + } + + $row['alias'] = $this->uniqueAlias($db, '#__content', (string) ($row['alias'] ?? ''), ['catid' => (int) ($row['catid'] ?? 0)]); + + try { + $table = $factory->createTable('Article', 'Administrator'); + $tagIds = $this->resolveTagIds($db, $app, $tagTitles[$oldId] ?? []); + + if ($tagIds) { + $table->newTags = $tagIds; + } + + if ($table->bind($row) && $table->check() && $table->store()) { + $newId = (int) $table->id; + $articleMap[$oldId] = $newId; + $count++; + + if (!empty($featured[$oldId])) { + try { + $db->insertObject($db->getPrefix() . 'content_frontpage', (object) ['content_id' => $newId, 'ordering' => 0]); + } catch (\Throwable $e) { + // featured-ordering row is non-critical + } + } + } else { + $this->log(' Article skipped: ' . $table->getError()); + } + } catch (\Throwable $e) { + $this->log(' Article "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); + } + } + + // Custom-field values for the duplicated articles. + foreach ($tables['#__fields_values'] ?? [] as $fv) { + $oldItem = (int) ($fv['item_id'] ?? 0); + + if (!isset($articleMap[$oldItem])) { + continue; + } + + $fv['item_id'] = (string) $articleMap[$oldItem]; + + try { + $db->insertObject($db->getPrefix() . 'fields_values', (object) $fv); + } catch (\Throwable $e) { + // duplicate/invalid field value — skip + } + } + } + + // --- Modules --- + if (in_array('modules', $types, true) && !empty($tables['#__modules'])) { + $factory = $app->bootComponent('com_modules')->getMVCFactory(); + + foreach ($tables['#__modules'] as $row) { + $oldId = (int) ($row['id'] ?? 0); + unset($row['id'], $row['asset_id']); + + try { + $table = $factory->createTable('Module', 'Administrator'); + + if ($table->bind($row) && $table->check() && $table->store()) { + $moduleMap[$oldId] = (int) $table->id; + $count++; + } else { + $this->log(' Module skipped: ' . $table->getError()); + } + } catch (\Throwable $e) { + $this->log(' Module "' . ($row['title'] ?? '?') . '" skipped: ' . $e->getMessage()); + } + } + + foreach ($tables['#__modules_menu'] ?? [] as $mm) { + $oldMod = (int) ($mm['moduleid'] ?? 0); + + if (!isset($moduleMap[$oldMod])) { + continue; + } + + try { + $db->insertObject($db->getPrefix() . 'modules_menu', (object) ['moduleid' => $moduleMap[$oldMod], 'menuid' => (int) ($mm['menuid'] ?? 0)]); + } catch (\Throwable $e) { + // duplicate assignment — skip + } + } + } + + $this->log('Duplicated ' . $count . ' new records (categories: ' . count($catMap) . ', articles: ' . count($articleMap) . ', modules: ' . count($moduleMap) . ')'); + + return $count; + } + + /** + * Make an alias unique within a scope by appending -2, -3, … if needed. + */ + private function uniqueAlias(object $db, string $abstractTable, string $alias, array $scope): string + { + $alias = $alias !== '' ? $alias : 'item'; + $table = str_replace('#__', $db->getPrefix(), $abstractTable); + $base = $alias; + $n = 1; + + while (true) { + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName($table)) + ->where($db->quoteName('alias') . ' = ' . $db->quote($alias)); + + foreach ($scope as $col => $val) { + $query->where($db->quoteName($col) . ' = ' . $db->quote($val)); + } + + if ((int) $db->setQuery($query)->loadResult() === 0) { + return $alias; + } + + $n++; + $alias = $base . '-' . $n; + + if ($n > 100) { + return $base . '-' . substr(md5((string) mt_rand()), 0, 6); + } + } + } + + /** + * Map old article id → its tag titles, from the snapshot's tag map + tags. + * + * @return array + */ + private function buildArticleTagTitles(array $tables): array + { + $tagTitle = []; + + foreach ($tables['#__tags'] ?? [] as $tag) { + $tagTitle[(int) ($tag['id'] ?? 0)] = (string) ($tag['title'] ?? ''); + } + + $byArticle = []; + + foreach ($tables['#__contentitem_tag_map'] ?? [] as $map) { + if (strpos((string) ($map['type_alias'] ?? ''), 'com_content.article') !== 0) { + continue; + } + + $articleId = (int) ($map['content_item_id'] ?? 0); + $title = $tagTitle[(int) ($map['tag_id'] ?? 0)] ?? ''; + + if ($articleId && $title !== '') { + $byArticle[$articleId][] = $title; + } + } + + return $byArticle; + } + + /** + * Resolve tag titles to target tag IDs, creating any that don't exist. + * + * @return int[] + */ + private function resolveTagIds(object $db, object $app, array $titles): array + { + $ids = []; + + foreach (array_unique($titles) as $title) { + try { + $existing = $db->setQuery( + $db->getQuery(true) + ->select($db->quoteName('id')) + ->from($db->quoteName('#__tags')) + ->where($db->quoteName('title') . ' = ' . $db->quote($title)) + )->loadResult(); + + if ($existing) { + $ids[] = (int) $existing; + continue; + } + + $table = $app->bootComponent('com_tags')->getMVCFactory()->createTable('Tag', 'Administrator'); + $table->setLocation(1, 'last-child'); + + if ($table->bind(['title' => $title, 'alias' => '', 'published' => 1, 'parent_id' => 1]) + && $table->check() && $table->store()) { + $ids[] = (int) $table->id; + } + } catch (\Throwable $e) { + // tag resolution is best-effort + } + } + + return $ids; + } + /** * Delete rows from a table, scoping to relevant content only. * diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index bbcf9069..6ced4f5a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -217,6 +217,7 @@ class SteppedBackupEngine 'progress' => 100, 'message' => 'Backup complete: ' . $session->archiveName, 'done' => true, + 'record_id' => $session->recordId, ]; } @@ -228,6 +229,7 @@ class SteppedBackupEngine 'progress' => $session->getProgress(), 'message' => $session->statusMessage, 'done' => $session->phase === 'complete', + 'record_id' => $session->recordId, ]; } catch (\Throwable $e) { $session->log('FATAL: ' . $e->getMessage()); @@ -806,24 +808,7 @@ class SteppedBackupEngine */ private function createUploaderFromParams(string $type, array $params): RemoteUploaderInterface { - $prefixMap = ['ftp' => 'ftp_', 'sftp' => 'sftp_', 's3' => 's3_', 'google_drive' => 'gdrive_']; - $prefix = $prefixMap[$type] ?? ''; - - $prefixed = []; - - foreach ($params as $key => $value) { - $prefixed[$prefix . $key] = $value; - } - - $fake = (object) $prefixed; - - return match ($type) { - 'ftp' => new FtpUploader($fake), - 'sftp' => new SftpUploader($fake), - 'google_drive' => new GoogleDriveUploader($fake), - 's3' => new S3Uploader($fake), - default => throw new \InvalidArgumentException('Unknown remote storage type: ' . $type), - }; + return RemoteUploaderFactory::create($type, $params); } } diff --git a/source/packages/com_mokosuitebackup/src/View/Runbackup/HtmlView.php b/source/packages/com_mokosuitebackup/src/View/Runbackup/HtmlView.php new file mode 100644 index 00000000..39e79740 --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/View/Runbackup/HtmlView.php @@ -0,0 +1,89 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\View\Runbackup; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Language\Text; +use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView; +use Joomla\CMS\Toolbar\ToolbarHelper; + +/** + * Full-screen "run a backup" progress view. + * + * Auto-starts the stepped backup (ajax.init → loop ajax.step) and shows a + * full-page progress screen. Used both by the dashboard "Backup Now" action + * and — via the system plugin's pre-update redirect — as the interstitial + * between clicking Joomla's Update and the update actually running. When a + * `returnurl` is supplied the page redirects there once the backup completes + * (e.g. back to `com_joomlaupdate&task=update.install&is_backed_up=1`). + */ +class HtmlView extends BaseHtmlView +{ + public int $profileId = 1; + + public string $profileTitle = ''; + + public string $description = ''; + + /** Raw (possibly base64) return URL from the request; validated in the layout. */ + public string $returnUrl = ''; + + public bool $autostart = true; + + public function display($tpl = null): void + { + $input = Factory::getApplication()->getInput(); + + $this->profileId = (int) $input->getInt('profile_id', $input->getInt('profileid', 1)); + $this->description = $input->getString('description', ''); + $this->returnUrl = $input->getRaw('returnurl', ''); + $this->autostart = (bool) $input->getInt('autostart', 1); + + if ($this->profileId <= 0) { + $this->profileId = 1; + } + + $this->profileTitle = $this->loadProfileTitle($this->profileId); + + $this->addToolbar(); + + parent::display($tpl); + } + + /** + * Look up the target profile's title for display (best-effort). + */ + private function loadProfileTitle(int $profileId): string + { + try { + $db = Factory::getContainer()->get(\Joomla\Database\DatabaseInterface::class); + $query = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = ' . (int) $profileId); + $db->setQuery($query); + + return (string) ($db->loadResult() ?? ''); + } catch (\Throwable $e) { + return ''; + } + } + + protected function addToolbar(): void + { + ToolbarHelper::title( + Text::_('COM_MOKOJOOMBACKUP_SHORT') . ': ' . Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_TITLE'), + 'archive' + ); + } +} diff --git a/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php b/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php index 565acd9e..e861acdc 100644 --- a/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php @@ -17,6 +17,9 @@ use Joomla\CMS\Session\Session; $ajaxToken = Session::getFormToken(); $ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false); + +$runbackupUrl = Route::_('index.php?option=com_mokosuitebackup&view=runbackup&autostart=1', false); +$liveSite = trim((string) \Joomla\CMS\Factory::getApplication()->get('live_site', '')); ?> defaultDirWarning) : ?> + + + +
@@ -244,7 +257,7 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) { - @@ -302,123 +315,18 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
- - - diff --git a/source/packages/com_mokosuitebackup/tmpl/runbackup/default.php b/source/packages/com_mokosuitebackup/tmpl/runbackup/default.php new file mode 100644 index 00000000..5f408e55 --- /dev/null +++ b/source/packages/com_mokosuitebackup/tmpl/runbackup/default.php @@ -0,0 +1,329 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Full-screen backup progress screen. Reuses the stepped-backup AJAX + * (ajax.init → loop ajax.step). On completion, redirects to a validated + * return URL (pre-update flow) or shows a completion panel (Backup Now). + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Language\Text; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Session\Session; +use Joomla\CMS\Uri\Uri; + +$ajaxToken = Session::getFormToken(); +$ajaxUrl = Route::_('index.php?option=com_mokosuitebackup&format=json', false); + +/* Validate the return URL to prevent an open redirect / javascript: XSS + (the value ends up in window.location.href). Accept ONLY: + - an absolute http(s) URL whose host matches this site, or + - a root-relative path starting with a single "/" (not "//"). + This rejects javascript:, data:, vbscript: (empty host) and + protocol-relative //evil.com. Falls back to '' (no redirect). */ +$safeReturnUrl = ''; + +if ($this->returnUrl !== '') { + $decoded = base64_decode($this->returnUrl, true); + $raw = ($decoded !== false && $decoded !== '') ? $decoded : $this->returnUrl; + + /* Reject any backslash or control char outright: legitimate Joomla admin + return URLs never contain them, and a browser normalises a leading "/\" + to "//", turning a would-be root-relative path into a protocol-relative + open redirect (e.g. "/\evil.com" → "//evil.com"). */ + if (preg_match('/[\x00-\x1f\x7f\\\\]/', $raw)) { + $raw = ''; + } + + $isAbsoluteHttp = (bool) preg_match('#^https?://#i', $raw); + $isRootRelative = isset($raw[0]) && $raw[0] === '/' && (!isset($raw[1]) || $raw[1] !== '/'); + + if ($isAbsoluteHttp || $isRootRelative) { + try { + $rootHost = Uri::getInstance(Uri::root())->getHost(); + $target = new Uri($raw); + $scheme = strtolower((string) $target->getScheme()); + + $schemeOk = $scheme === '' || $scheme === 'http' || $scheme === 'https'; + $hostOk = $target->getHost() === '' || strcasecmp($target->getHost(), $rootHost) === 0; + + if ($schemeOk && $hostOk) { + $safeReturnUrl = $raw; + } + } catch (\Throwable $e) { + $safeReturnUrl = ''; + } + } +} + +$dashboardUrl = Route::_('index.php?option=com_mokosuitebackup&view=dashboard', false); + +$config = [ + 'ajaxUrl' => $ajaxUrl, + 'token' => $ajaxToken, + 'profileId' => $this->profileId, + 'description' => $this->description, + 'returnUrl' => $safeReturnUrl, + 'dashboardUrl' => $dashboardUrl, + 'recordUrl' => Route::_('index.php?option=com_mokosuitebackup&view=backup&id=__MSBID__', false), + 'autostart' => $this->autostart, + 'labels' => [ + 'starting' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), + 'running' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RUNNING'), + 'complete' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_COMPLETE'), + 'continuing' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUING'), + 'failed' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_FAILED'), + 'retry' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_RETRY'), + 'continue' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_CONTINUE_ANYWAY'), + 'dashboard' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_BACK_TO_DASHBOARD'), + 'viewRecord' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_VIEW_RECORD'), + 'leaveWarn' => Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_LEAVE_WARNING'), + ], +]; +?> + +
+
+
+

+ + +

+

+ profileTitle !== '' + ? Text::sprintf('COM_MOKOJOOMBACKUP_RUNBACKUP_PROFILE', $this->profileTitle) + : Text::_('COM_MOKOJOOMBACKUP_RUNBACKUP_STARTING'), + ENT_QUOTES, + 'UTF-8' + ); ?> +

+ +
+
+ +
+ + + + +
+
+ + +
+
0%
+
+
+
+ + diff --git a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php index 53fce3e0..f2508b97 100644 --- a/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/snapshots/default.php @@ -27,6 +27,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
+ +
+ +
+ items)) : ?>
@@ -112,6 +120,11 @@ $listDirn = $this->escape($this->state->get('list.direction')); title=""> + + + id; ?> @@ -131,6 +144,32 @@ $listDirn = $this->escape($this->state->get('list.direction'));
+ + +