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. *