feat: standalone restore script — separate file that scans for ZIPs (#107)
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 53s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8m41s
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Failing after 3s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Access control (pull_request) Successful in 3s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 16s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 53s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 31s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 8m41s
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -83,14 +83,14 @@
|
||||
/>
|
||||
<field
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="encryption_password"
|
||||
|
||||
@@ -133,8 +133,11 @@ COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
|
||||
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
|
||||
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
|
||||
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
|
||||
@@ -237,26 +237,32 @@ class BackupEngine
|
||||
$this->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);
|
||||
|
||||
@@ -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'
|
||||
<!-- Backup File Selector (standalone mode) -->
|
||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||
<h2 class="mr-step-title">Select Backup File</h2>
|
||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.className = 'mr-alert mr-alert-danger';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.className = 'mr-alert mr-alert-success';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'mr-field-group';
|
||||
backups.forEach(function(b) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '8px';
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
label.appendChild(nameStrong);
|
||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||
group.appendChild(label);
|
||||
});
|
||||
list.appendChild(group);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
SELECTOR;
|
||||
|
||||
/* Insert the selector before the extract step in the HTML */
|
||||
$html = str_replace(
|
||||
'<!-- Step: Extract -->',
|
||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||
$html
|
||||
);
|
||||
|
||||
return $php . $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user