From e62dba8f40ee07f13245a8497eae766e1b625e43 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 11:20:23 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20standalone=20restore=20script=20?= =?UTF-8?q?=E2=80=94=20separate=20file=20that=20scans=20for=20ZIPs=20(#107?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MokoRestore mode: 'standalone' generates restore.php as a separate file that scans its directory for ZIP backup archives and lets the user choose which one to restore. Unlike 'wrapped' mode which bundles restore.php inside the backup ZIP, standalone mode keeps both files separate — ideal for remote servers where you SCP the backup. Changes: - MokoRestore::generateStandalone() — writes restore.php with ZIP scanner - Profile form: include_mokorestore now a dropdown (none/wrapped/standalone) - BackupEngine: standalone mode writes restore.php + uploads to remote - Restore script uses safe DOM methods (no innerHTML with user data) Closes #107 --- CHANGELOG.md | 5 + .../com_mokosuitebackup/forms/profile.xml | 8 +- .../language/en-GB/com_mokosuitebackup.ini | 7 +- .../src/Engine/BackupEngine.php | 28 ++- .../src/Engine/MokoRestore.php | 185 ++++++++++++++++++ 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00aabeb..9a846da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Added +- Standalone restore script mode — restore.php as separate file that scans for backup ZIPs in its directory (#107) +- MokoRestore profile option: None / Wrapped / Standalone +- Standalone mode uploads restore.php alongside backup to remote storage + ## [01.37.00] --- 2026-06-23 ## [01.37.00] --- 2026-06-23 diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 131fad5..cf02521 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -83,14 +83,14 @@ /> - - + + + verifyArchive($archivePath, $profile->backup_type); $this->log('Archive integrity verified'); - // Step 2.5: Wrap with MokoRestore script (if enabled) - $includeMokoRestore = (bool) ($profile->include_mokorestore ?? false); + // Step 2.5: MokoRestore script (if enabled) + $mokoRestoreMode = $profile->include_mokorestore ?? '0'; + $restoreScriptPath = ''; - if ($includeMokoRestore) { + if ($mokoRestoreMode === '1') { + // Wrapped mode: backup ZIP inside an outer ZIP with restore.php $this->log('Wrapping with MokoRestore script...'); $mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName); $mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName; MokoRestore::wrap($archivePath, $mokoRestorePath); - // Replace the original archive with the wrapped one if (is_file($archivePath) && !unlink($archivePath)) { $this->log('WARNING: Could not remove pre-wrap archive'); } rename($mokoRestorePath, $archivePath); $totalSize = filesize($archivePath); $sizeHuman = number_format($totalSize / 1048576, 2) . ' MB'; - // Recompute checksum for the final wrapped archive $checksum = hash_file('sha256', $archivePath); $this->log('MokoRestore archive created: ' . $sizeHuman); $this->log('SHA-256 (wrapped): ' . $checksum); + } elseif ($mokoRestoreMode === 'standalone') { + // Standalone mode: restore.php as a separate file next to the backup ZIP + $this->log('Generating standalone restore.php...'); + $restoreScriptPath = $this->backupDir . '/restore.php'; + MokoRestore::generateStandalone($restoreScriptPath); + $this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)'); } $remoteFilename = ''; @@ -277,6 +283,18 @@ class BackupEngine $remoteFilename = $uploadResult['remote_path'] ?? $archiveName; $this->log('Remote upload complete: ' . $uploadResult['message']); + // Upload standalone restore.php alongside the backup if in standalone mode + if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { + $this->log('Uploading standalone restore.php...'); + $restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php'); + + if ($restoreUpload['success']) { + $this->log('Standalone restore.php uploaded'); + } else { + $this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']); + } + } + // Delete local copy if configured if (empty($profile->remote_keep_local) && is_file($archivePath)) { @unlink($archivePath); diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index 06254ed..fc65438 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -54,6 +54,191 @@ class MokoRestore return $outputPath; } + /** + * Generate the standalone restore.php script as a separate file. + * + * Unlike the wrapped version, this script scans its own directory + * for ZIP files and lets the user choose which one to restore from. + * + * @param string $outputPath Where to write restore.php + * + * @return string Path to the generated script + */ + public static function generateStandalone(string $outputPath): string + { + $script = self::generateStandaloneScript(); + + if (file_put_contents($outputPath, $script) === false) { + throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath); + } + + return $outputPath; + } + + /** + * Generate the standalone script content that scans for ZIPs. + */ + private static function generateStandaloneScript(): string + { + /* Take the normal backend but replace the hardcoded BACKUP_FILE + with a directory scanner that finds ZIP files */ + $php = self::generateBackend(); + + /* Replace the fixed BACKUP_FILE constant with dynamic scanner */ + $php = str_replace( + "define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');", + "/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" . + "define('BACKUP_FILE', ''); /* placeholder — overridden per request */", + $php + ); + + /* Inject the backup scanner function after the constants */ + $scannerCode = <<<'SCANNER' + +/** + * Scan the restore directory for ZIP files that look like backups. + */ +function scanForBackups(): array +{ + $dir = RESTORE_DIR; + $files = []; + + foreach (glob($dir . '/*.zip') as $path) { + $name = basename($path); + + /* Skip the restore script wrapper if present */ + if ($name === 'restore.php') { + continue; + } + + $files[] = [ + 'name' => $name, + 'path' => $path, + 'size' => filesize($path), + 'date' => date('Y-m-d H:i:s', filemtime($path)), + ]; + } + + /* Sort by modification time, newest first */ + usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path'])); + + return $files; +} + +/** + * Handle backup file selection and set the working file. + */ +function getSelectedBackupFile(): string +{ + if (!empty($_POST['backup_file'])) { + $selected = basename($_POST['backup_file']); /* sanitize — basename only */ + $path = RESTORE_DIR . '/' . $selected; + + if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) { + return $path; + } + } + + /* Auto-select if only one ZIP exists */ + $backups = scanForBackups(); + + if (count($backups) === 1) { + return $backups[0]['path']; + } + + return ''; +} + +SCANNER; + + /* Insert scanner after the opening PHP section but before the action handlers */ + $php = str_replace( + "/* ── Action Handlers", + $scannerCode . "\n/* ── Action Handlers", + $php + ); + + /* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */ + $php = str_replace( + '$zip->open(BACKUP_FILE)', + '$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)', + $php + ); + + /* Modify the pre-checks to use getSelectedBackupFile() */ + $php = str_replace( + "file_exists(BACKUP_FILE)", + "(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))", + $php + ); + + $html = self::generateFrontend(); + + /* Add backup file selector to the frontend before the extract step */ + $selectorHtml = <<<'SELECTOR' + + + +SELECTOR; + + /* Insert the selector before the extract step in the HTML */ + $html = str_replace( + '', + $selectorHtml . "\n", + $html + ); + + return $php . $html; + } + /** * Generate the standalone restore.php script. *