diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..12adab7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "source/packages/MokoSuiteClient"] + path = source/packages/MokoSuiteClient + url = https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteClient.git diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index 98af20f..55502e4 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: mokocli.Automation -# VERSION: 01.43.00 +# VERSION: 01.43.25 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e02f3..2d8435f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +### Added +- Customizable restore script filename per backup profile (reduces discoverability on remote servers) +- MokoRestore standalone mode: multi-ZIP selector when multiple backup archives are present +- MokoRestore preflight: Joomla installation detection warning before overwriting an existing site +- MokoRestore error handling: try/catch on fetch calls, HTTP status checks, JSON parse recovery +- Download button on individual backup record detail toolbar +- Profile column in backup records list links to the profile edit view + +### Changed +- Moved download, browse archive, and view log actions from backup list rows into the individual backup record view +- Removed "Run Backup" / "Backup Now" buttons from profiles list, profile edit toolbar, and backup records view (backups are triggered from the dashboard only) +- Removed ordering field from profiles; default sort is now by ID ascending +- MokoRestore cleanup and security messages now reference the actual script filename instead of hardcoded "restore.php" + +### Fixed +- Bootstrap 5 modal conversion for snapshots view (data-bs-dismiss, modal-footer, getOrCreateInstance) +- ntfy default URL changed from ntfy.sh to ntfy.mokoconsulting.tech +- Untranslated JFIELD_ORDERING_ASC / JFIELD_ORDERING_LABEL language keys replaced with component-specific keys +- Options page title now shows "MokoSuiteBackup Options" instead of raw language key +- Profile dropdown IDs in backup records and dashboard show "#ID — Title (type)" format +- MokoRestore stalling: unhandled promise rejections from network errors or non-JSON responses left UI in loading state + ## [01.43.00] --- 2026-06-24 @@ -71,7 +93,7 @@ - Backup comparison: select two backups for side-by-side diff - Archive browser: view files inside backup without extracting - Manual purge: delete backups older than a date with count preview -- Run Backup button on profile list and edit views with backup count badges +- Backup count badges on profile list - "Do not navigate away" warning in backup/restore progress modals - Clickable placeholder pills for backup directory and archive name fields - Comprehensive help modal with absolute/relative/placeholder path documentation diff --git a/README.md b/README.md index edf4659..b949bbb 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Full-site backup and restore for Joomla — database, files, and configuration. | Field | Value | |---|---| | **Package** | `pkg_mokosuitebackup` | -| **Type** | Joomla Package (8 sub-extensions) | +| **Type** | Joomla Package (9 sub-extensions + MokoSuiteClient) | | **Joomla** | 6.x+ | | **PHP** | 8.1+ | | **License** | GPL-3.0-or-later | @@ -30,7 +30,8 @@ Full-site backup and restore for Joomla — database, files, and configuration. - Scheduled snapshot task via com_scheduler ### Remote Storage -- SFTP with SSH key file authentication (key stored base64-encoded in database) +- Multi-remote — upload to multiple destinations per profile simultaneously +- SFTP with SSH key file auth + remote directory browser - Amazon S3 and S3-compatible (DigitalOcean Spaces, Wasabi, MinIO) - Google Drive with OAuth2 and resumable uploads - Graceful degradation — local backup preserved if upload fails @@ -66,6 +67,10 @@ Full-site backup and restore for Joomla — database, files, and configuration. - Snapshots: create, list, restore, delete, download - Profile credentials masked in API responses +### Bundled: MokoSuiteClient +- Full MokoSuiteClient package installed automatically alongside MokoSuiteBackup +- Provides admin dashboard, security firewall, tenant management, and developer tools + ## Installation 1. Download from [Releases](https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteBackup/releases) diff --git a/source/packages/MokoSuiteClient b/source/packages/MokoSuiteClient new file mode 160000 index 0000000..9df6bea --- /dev/null +++ b/source/packages/MokoSuiteClient @@ -0,0 +1 @@ +Subproject commit 9df6bea4b7480b2e443898ad84a279070ba4a7f6 diff --git a/source/packages/com_mokosuitebackup/config.xml b/source/packages/com_mokosuitebackup/config.xml index ff63899..923006a 100644 --- a/source/packages/com_mokosuitebackup/config.xml +++ b/source/packages/com_mokosuitebackup/config.xml @@ -245,7 +245,7 @@ type="text" label="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER" description="COM_MOKOJOOMBACKUP_CONFIG_NTFY_SERVER_DESC" - default="https://ntfy.sh" + default="https://ntfy.mokoconsulting.tech" filter="url" /> - diff --git a/source/packages/com_mokosuitebackup/forms/profile.xml b/source/packages/com_mokosuitebackup/forms/profile.xml index 661673d..81a46de 100644 --- a/source/packages/com_mokosuitebackup/forms/profile.xml +++ b/source/packages/com_mokosuitebackup/forms/profile.xml @@ -93,6 +93,16 @@ + JPUBLISHED -
diff --git a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini index 8942f04..8547103 100644 --- a/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini +++ b/source/packages/com_mokosuitebackup/language/en-GB/com_mokosuitebackup.ini @@ -5,6 +5,7 @@ ; @license GPL-3.0-or-later COM_MOKOJOOMBACKUP="MokoSuiteBackup" +COM_MOKOJOOMBACKUP_CONFIGURATION="MokoSuiteBackup Options" COM_MOKOJOOMBACKUP_DESCRIPTION="Full-site backup and restore for Joomla" ; Submenu @@ -41,6 +42,8 @@ COM_MOKOJOOMBACKUP_DASHBOARD_STORAGE_BREAKDOWN="Storage by Profile" COM_MOKOJOOMBACKUP_DASHBOARD_BACKUP_TREND="Backup Trend (30 days)" ; Backups view +COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED="%d backup records deleted." +COM_MOKOJOOMBACKUP_BACKUPS_N_ITEMS_DELETED_1="%d backup record deleted." COM_MOKOJOOMBACKUP_BACKUPS_TITLE="Backup Records" COM_MOKOJOOMBACKUP_BACKUPS_TABLE_CAPTION="Table of backup records" COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup." @@ -139,6 +142,8 @@ COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="None: no restore script. Wrap COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None" COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)" COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)" +COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME="Restore Script Filename" +COM_MOKOJOOMBACKUP_FIELD_RESTORE_SCRIPT_NAME_DESC="Custom filename for the restore script. Must end in .php. Use a non-obvious name to reduce discoverability on remote servers (e.g. moko-install-xyz.php)." ; Data Sanitization COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization" @@ -275,9 +280,9 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_PORT_DESC="SSH port (default: 22)" COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME="SSH Username" COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication" COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password" -COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file." +COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication." COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key" -COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only. Leave blank for password auth." +COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload your SSH private key (id_rsa, id_ed25519). Stored base64-encoded in DB, written to temp file during upload only." COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File" COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key" COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded" diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index a9d7540..05798ab 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,8 @@ --> MokoSuiteBackup - 01.43.00 + 01.43.25 + 01.43.25 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/sql/install.mysql.sql b/source/packages/com_mokosuitebackup/sql/install.mysql.sql index 8767c04..76be0f7 100644 --- a/source/packages/com_mokosuitebackup/sql/install.mysql.sql +++ b/source/packages/com_mokosuitebackup/sql/install.mysql.sql @@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` ( `remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', `encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)', `include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone', + `restore_script_name` VARCHAR(100) NOT NULL DEFAULT 'restore.php' COMMENT 'Custom restore script filename', `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value', `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing', `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values', @@ -113,14 +114,13 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_remotes` ( `title` VARCHAR(255) NOT NULL DEFAULT '', `type` VARCHAR(20) NOT NULL DEFAULT 'sftp' COMMENT 'sftp, s3, google_drive', `enabled` TINYINT(1) NOT NULL DEFAULT 1, - `keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload', - `config` MEDIUMTEXT NOT NULL COMMENT 'JSON — type-specific settings', + `params` MEDIUMTEXT COMMENT 'JSON: type-specific settings', `ordering` INT(11) NOT NULL DEFAULT 0, `created` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', `modified` DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (`id`), KEY `idx_profile` (`profile_id`), - KEY `idx_enabled` (`enabled`) + KEY `idx_enabled` (`profile_id`, `enabled`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Insert default backup profile (IGNORE prevents duplicate key error on update) diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.11.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.11.sql new file mode 100644 index 0000000..ef3d02e --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.11.sql @@ -0,0 +1 @@ +/* 01.43.11 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.19.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.19.sql new file mode 100644 index 0000000..09ac944 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.19.sql @@ -0,0 +1 @@ +/* 01.43.19 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.20.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.20.sql new file mode 100644 index 0000000..e9eb4e2 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.20.sql @@ -0,0 +1 @@ +/* 01.43.20 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.22.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.22.sql new file mode 100644 index 0000000..01f1e95 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.22.sql @@ -0,0 +1,5 @@ +-- 01.43.22 — Add restore_script_name to profiles, align remotes schema + +ALTER TABLE `#__mokosuitebackup_profiles` + ADD COLUMN `restore_script_name` VARCHAR(100) NOT NULL DEFAULT 'restore.php' COMMENT 'Custom restore script filename' + AFTER `include_mokorestore`; diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.23.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.23.sql new file mode 100644 index 0000000..08a0685 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.23.sql @@ -0,0 +1 @@ +/* 01.43.23 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.24.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.24.sql new file mode 100644 index 0000000..64643df --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.24.sql @@ -0,0 +1 @@ +/* 01.43.24 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.25.sql b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.25.sql new file mode 100644 index 0000000..6ae1419 --- /dev/null +++ b/source/packages/com_mokosuitebackup/sql/updates/mysql/01.43.25.sql @@ -0,0 +1 @@ +/* 01.43.25 — no schema changes */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index 52f0a58..5eb994c 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -259,14 +259,14 @@ class BackupEngine // Step 2.5: MokoRestore script (if enabled) $mokoRestoreMode = $profile->include_mokorestore ?? '0'; + $restoreScriptName = $profile->restore_script_name ?? 'restore.php'; $restoreScriptPath = ''; 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); + MokoRestore::wrap($archivePath, $mokoRestorePath, $restoreScriptName); if (is_file($archivePath) && !unlink($archivePath)) { $this->log('WARNING: Could not remove pre-wrap archive'); @@ -278,11 +278,11 @@ class BackupEngine $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'; + $restoreScriptName = MokoRestore::sanitizeScriptName($restoreScriptName); + $this->log('Generating standalone ' . $restoreScriptName . '...'); + $restoreScriptPath = $this->backupDir . '/' . $restoreScriptName; MokoRestore::generateStandalone($restoreScriptPath); - $this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)'); + $this->log('Standalone ' . $restoreScriptName . ' generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)'); } $remoteFilename = ''; @@ -303,9 +303,8 @@ class BackupEngine $remoteFilename = $result['remote_path'] ?? $archiveName; $this->log(' Upload complete: ' . $result['message']); - /* Upload standalone restore.php if in standalone mode */ if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) { - $uploader->upload($restoreScriptPath, 'restore.php'); + $uploader->upload($restoreScriptPath, basename($restoreScriptPath)); } } else { $uploadFailed = true; @@ -336,15 +335,15 @@ 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'); + $restoreBasename = basename($restoreScriptPath); + $this->log('Uploading standalone ' . $restoreBasename . '...'); + $restoreUpload = $uploader->upload($restoreScriptPath, $restoreBasename); if ($restoreUpload['success']) { - $this->log('Standalone restore.php uploaded'); + $this->log('Standalone ' . $restoreBasename . ' uploaded'); } else { - $this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']); + $this->log('WARNING: ' . $restoreBasename . ' upload failed: ' . $restoreUpload['message']); } } diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index f72f513..78c4b2a 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -35,25 +35,36 @@ class MokoRestore * * @return string Path to the wrapped archive */ - public static function wrap(string $backupArchive, string $outputPath): string + public static function wrap(string $backupArchive, string $outputPath, string $scriptName = 'restore.php'): string { + $scriptName = self::sanitizeScriptName($scriptName); + $zip = new \ZipArchive(); if ($zip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('Cannot create MokoRestore archive: ' . $outputPath); } - // Add the standalone restore script - $zip->addFromString('restore.php', self::generateRestoreScript()); - - // Add the original backup as a nested ZIP + $zip->addFromString($scriptName, self::generateRestoreScript()); $zip->addFile($backupArchive, 'site-backup.zip'); - $zip->close(); return $outputPath; } + public static function sanitizeScriptName(string $name): string + { + $name = basename(trim($name)); + + if ($name === '' || !str_ends_with(strtolower($name), '.php')) { + $name = 'restore.php'; + } + + $name = preg_replace('/[^a-zA-Z0-9._-]/', '', $name); + + return $name ?: 'restore.php'; + } + /** * Generate the standalone restore.php script as a separate file. * @@ -165,7 +176,38 @@ SCANNER; $php ); - /* Modify the pre-checks to use getSelectedBackupFile() */ + /* Replace the backup archive check with one that scans for ZIPs + (must run BEFORE the blanket file_exists replacement below) */ + $php = str_replace( + <<<'ORIG' + $checks[] = [ + 'label' => 'Backup Archive', + 'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found', + 'ok' => file_exists(BACKUP_FILE), + 'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']), + ]; +ORIG, + <<<'REPL' + $availableBackups = scanForBackups(); + $backupCount = count($availableBackups); + $selectedFile = getSelectedBackupFile(); + if ($selectedFile && file_exists($selectedFile)) { + $archiveValue = basename($selectedFile) . ' (' . number_format(filesize($selectedFile) / 1048576, 2) . ' MB)'; + } elseif ($backupCount > 0) { + $archiveValue = $backupCount . ' ZIP file(s) found'; + } else { + $archiveValue = 'No ZIP files found'; + } + $checks[] = [ + 'label' => 'Backup Archive', + 'value' => $archiveValue, + 'ok' => $backupCount > 0, + 'hint' => 'Place one or more backup ZIP files in the same directory as ' . basename($_SERVER['SCRIPT_NAME']), + ]; +REPL + ); + + /* Modify remaining pre-checks to use getSelectedBackupFile() */ $php = str_replace( "file_exists(BACKUP_FILE)", "(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))", @@ -174,65 +216,83 @@ SCANNER; $html = self::generateFrontend(); - /* Add backup file selector to the frontend before the extract step */ + /* Inject backup file selector into the extract step (panel2) */ $selectorHtml = <<<'SELECTOR' - - - + if (backups.length === 0) { + var alert = document.createElement('div'); + alert.style.cssText = 'padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626;'; + 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.style.cssText = 'padding:12px;background:#dcfce7;border:1px solid #bbf7d0;border-radius:6px;color:#16a34a;'; + 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 hint = document.createElement('div'); + hint.style.cssText = 'padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;color:#1d4ed8;margin-bottom:8px;font-size:0.9em;'; + hint.textContent = 'Multiple backup archives found \u2014 select which one to restore:'; + list.appendChild(hint); + backups.forEach(function(b, i) { + var label = document.createElement('label'); + label.style.cssText = 'display:flex;align-items:center;padding:10px 12px;margin:4px 0;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:background 0.15s;'; + label.onmouseover = function() { this.style.background = '#f8fafc'; }; + label.onmouseout = function() { this.style.background = ''; }; + var radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'backup_choice'; + radio.value = b.name; + radio.style.marginRight = '10px'; + if (i === 0) { radio.checked = true; hiddenInput.value = b.name; } + radio.addEventListener('change', function() { hiddenInput.value = this.value; }); + label.appendChild(radio); + var info = document.createElement('div'); + var nameStrong = document.createElement('strong'); + nameStrong.textContent = b.name; + info.appendChild(nameStrong); + var meta = document.createElement('div'); + meta.style.cssText = 'font-size:0.85em;color:#64748b;margin-top:2px;'; + meta.textContent = (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date; + info.appendChild(meta); + label.appendChild(info); + list.appendChild(label); + }); + } + })(); + SELECTOR; - /* Insert the selector before the extract step in the HTML */ + /* Insert the selector into the extract panel */ $html = str_replace( - '', - $selectorHtml . "\n", + '

Extract site-backup.zip into the current directory.

', + '

Select a backup archive and extract it into the current directory.

' . "\n" . $selectorHtml, + $html + ); + + /* Pass selected backup file to the extract action */ + $html = str_replace( + "const r = await post('extract', pw ? { archive_password: pw } : {});", + "var extraParams = {};\n" . + " if (pw) extraParams.archive_password = pw;\n" . + " var sel = document.getElementById('mr-backup-file');\n" . + " if (sel && sel.value) extraParams.backup_file = sel.value;\n" . + " const r = await post('extract', extraParams);", $html ); @@ -435,7 +495,7 @@ function actionPreflight(): array 'label' => 'Backup Archive', 'value' => file_exists(BACKUP_FILE) ? number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB' : 'Not found', 'ok' => file_exists(BACKUP_FILE), - 'hint' => 'site-backup.zip must be in the same directory as restore.php', + 'hint' => 'site-backup.zip must be in the same directory as ' . basename($_SERVER['SCRIPT_NAME']), ]; $checks[] = [ @@ -462,15 +522,31 @@ function actionPreflight(): array 'hint' => 'Informational', ]; + $joomlaExists = file_exists(RESTORE_DIR . '/configuration.php') + || file_exists(RESTORE_DIR . '/libraries/src/Version.php'); + $checks[] = [ + 'label' => 'Existing Installation', + 'value' => $joomlaExists ? 'Joomla detected' : 'Clean directory', + 'ok' => true, + 'warn' => $joomlaExists, + 'hint' => $joomlaExists + ? 'WARNING: A Joomla installation already exists in this directory. Restoring will overwrite it.' + : 'No existing installation found — safe to proceed', + ]; + $allOk = true; + $warnings = []; foreach ($checks as $c) { if (!$c['ok']) { $allOk = false; } + if (!empty($c['warn'])) { + $warnings[] = $c['hint']; + } } - return ['success' => $allOk, 'checks' => $checks]; + return ['success' => $allOk, 'checks' => $checks, 'warnings' => $warnings]; } function actionExtract(array $data): array @@ -1425,6 +1501,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N .mr-checks li:last-child{border-bottom:none} .mr-check-icon{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0} .mr-check-ok{background:#dcfce7;color:#16a34a} +.mr-check-warn{background:#fef9c3;color:#a16207} .mr-check-fail{background:#fef2f2;color:#dc2626} .mr-check-info{background:#e0f2fe;color:#0284c7} .mr-check-label{flex:1;font-weight:500} @@ -1474,7 +1551,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- Security: Delete restore.php immediately after installation is complete. + Security: Delete immediately after installation is complete.
@@ -1722,7 +1799,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N Success! The site restoration is complete.
- Important: Delete restore.php and site-backup.zip from your server immediately for security. + Important: Delete and site-backup.zip from your server immediately for security.
@@ -1746,6 +1823,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N -