From 0dc0eb1bef0c6409331ff2f4a6cfb4a54da524c2 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 23 Jun 2026 12:33:18 -0500 Subject: [PATCH] feat: MokoRestore post-restore resets + per-table conflict resolution (#131, #132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #131: Post-restore actions step in MokoRestore wizard: - Reset all passwords to temporary "changeme" with requireReset flag - Reset article hit counters to zero - Clear content versions (#__history) - Clear sessions (#__session) - Clear cache tables and filesystem cache - Auto-detect sanitized password hashes and prompt for reset #132: Per-table conflict resolution during database import: - New "Tables" step shows all tables from database.sql - Per-table dropdown: Replace / Skip / Merge / Data Only - Preset buttons: All Replace, All Skip, Everything except users - Skip mode skips all statements for that table - Merge mode uses INSERT IGNORE instead of INSERT INTO - Data Only skips DROP/CREATE, inserts data only Wizard now has 9 steps: Pre-check → Extract → Tables → Database → Config → Admin → Post-Restore → Provisioning → Complete Closes #131, closes #132 --- CHANGELOG.md | 7 + .../src/Engine/MokoRestore.php | 543 ++++++++++++++++-- 2 files changed, 507 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3df88a..b85ca93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added +- MokoRestore: post-restore reset options — passwords, hits, versions, sessions, cache (#131) +- MokoRestore: per-table conflict resolution — replace, skip, merge, data-only per table (#132) +- MokoRestore: preset buttons — "All Replace", "All Skip", "Everything except users" +- MokoRestore: auto-detect sanitized passwords and prompt for reset +- Data sanitization: passwords, emails, sessions in backup profile settings (#129) + ## [01.38.05] --- 2026-06-23 ## [01.38.05] --- 2026-06-23 diff --git a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php index fc65438..c01354b 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php +++ b/source/packages/com_mokosuitebackup/src/Engine/MokoRestore.php @@ -376,16 +376,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { function handleAction(string $action, array $data): array { return match ($action) { - 'preflight' => actionPreflight(), - 'extract' => actionExtract($data), - 'testdb' => actionTestDb($data), - 'database' => actionDatabase($data), - 'config' => actionConfig($data), - 'listAdmins' => actionListAdmins($data), - 'resetAdmin' => actionResetAdmin($data), - 'provision' => actionProvision($data), - 'cleanup' => actionCleanup(), - default => ['success' => false, 'message' => 'Unknown action: ' . $action], + 'preflight' => actionPreflight(), + 'extract' => actionExtract($data), + 'scanTables' => actionScanTables(), + 'testdb' => actionTestDb($data), + 'database' => actionDatabase($data), + 'config' => actionConfig($data), + 'listAdmins' => actionListAdmins($data), + 'resetAdmin' => actionResetAdmin($data), + 'postRestore' => actionPostRestore($data), + 'detectSanitized' => detectSanitizedPasswords($data), + 'provision' => actionProvision($data), + 'cleanup' => actionCleanup(), + default => ['success' => false, 'message' => 'Unknown action: ' . $action], }; } @@ -551,6 +554,65 @@ function actionExtract(array $data): array ]; } +/** + * Parse database.sql and extract the list of table names. + * Returns table names using the abstract #__ prefix so the UI + * can display them before the user's target prefix is known. + */ +function actionScanTables(): array +{ + $sqlFile = RESTORE_DIR . '/database.sql'; + + if (!is_file($sqlFile)) { + return ['success' => true, 'tables' => [], 'message' => 'No database.sql found']; + } + + $sql = file_get_contents($sqlFile); + $tables = []; + + // Match DROP TABLE IF EXISTS `#__tablename` or CREATE TABLE ... `#__tablename` + if (preg_match_all('/(?:DROP\s+TABLE\s+IF\s+EXISTS|CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?)\s+`([^`]+)`/i', $sql, $matches)) { + foreach ($matches[1] as $name) { + if (!in_array($name, $tables, true)) { + $tables[] = $name; + } + } + } + + // Sort alphabetically for easier scanning + sort($tables, SORT_STRING); + + return [ + 'success' => true, + 'tables' => $tables, + 'count' => count($tables), + ]; +} + +/** + * Determine which table a SQL statement belongs to. + * Returns the table name (with the prefix already applied) or empty string. + */ +function getStatementTable(string $stmt): string +{ + // DROP TABLE IF EXISTS `prefix_tablename` + if (preg_match('/^DROP\s+TABLE\s+IF\s+EXISTS\s+`([^`]+)`/i', $stmt, $m)) { + return $m[1]; + } + + // CREATE TABLE `prefix_tablename` or CREATE TABLE IF NOT EXISTS `prefix_tablename` + if (preg_match('/^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`([^`]+)`/i', $stmt, $m)) { + return $m[1]; + } + + // INSERT INTO `prefix_tablename` + if (preg_match('/^INSERT\s+INTO\s+`([^`]+)`/i', $stmt, $m)) { + return $m[1]; + } + + return ''; +} + function actionTestDb(array $data): array { $host = $data['db_host'] ?? 'localhost'; @@ -608,10 +670,27 @@ function actionDatabase(array $data): array // Replace abstract #__ prefix with the user's target prefix $sql = str_replace('#__', $prefix, $sql); + // Decode per-table conflict resolution selections + // Keys are abstract table names (#__xxx), values are: replace|skip|merge|dataonly + $tableResolutions = []; + + if (!empty($data['table_resolutions'])) { + $decoded = json_decode($data['table_resolutions'], true); + + if (is_array($decoded)) { + // Remap from abstract #__ names to the real prefix + foreach ($decoded as $abstractName => $mode) { + $realName = str_replace('#__', $prefix, $abstractName); + $tableResolutions[$realName] = $mode; + } + } + } + $parts = explode(";\n", $sql); $statements = 0; $errors = 0; $errorList = []; + $skipped = 0; foreach ($parts as $part) { $part = trim($part); @@ -620,6 +699,39 @@ function actionDatabase(array $data): array continue; } + // Determine which table this statement belongs to + $table = getStatementTable($part); + $mode = $tableResolutions[$table] ?? 'replace'; + + // Apply conflict resolution per table + if ($mode === 'skip') { + $skipped++; + continue; + } + + $isDrop = (bool) preg_match('/^DROP\s+TABLE/i', $part); + $isCreate = (bool) preg_match('/^CREATE\s+TABLE/i', $part); + $isInsert = (bool) preg_match('/^INSERT\s+INTO/i', $part); + + if ($mode === 'merge') { + // Skip DROP and CREATE; convert INSERT INTO to INSERT IGNORE INTO + if ($isDrop || $isCreate) { + $skipped++; + continue; + } + + if ($isInsert) { + $part = preg_replace('/^INSERT\s+INTO/i', 'INSERT IGNORE INTO', $part); + } + } elseif ($mode === 'dataonly') { + // Skip DROP and CREATE; execute INSERT as-is + if ($isDrop || $isCreate) { + $skipped++; + continue; + } + } + // mode === 'replace' => execute everything as-is (default) + try { $pdo->exec($part); $statements++; @@ -634,11 +746,22 @@ function actionDatabase(array $data): array $pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); + $msg = "Executed {$statements} statements"; + + if ($skipped > 0) { + $msg .= " ({$skipped} skipped)"; + } + + if ($errors > 0) { + $msg .= " ({$errors} warnings)"; + } + return [ 'success' => ($statements > 0 || $errors === 0), - 'message' => "Executed {$statements} statements" . ($errors ? " ({$errors} warnings)" : ''), + 'message' => $msg, 'statements' => $statements, 'errors' => $errors, + 'skipped' => $skipped, 'errorList' => $errorList, ]; } @@ -989,6 +1112,127 @@ function actionResetAdmin(array $data): array return ['success' => true, 'message' => 'Admin password updated successfully']; } +function actionPostRestore(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = getValidatedPrefix($data); + $tasks = json_decode($data['tasks'] ?? '[]', true) ?: []; + $results = []; + + foreach ($tasks as $task) { + try { + switch ($task) { + case 'reset_passwords': + // Set all user passwords to a known temporary hash ("changeme"), + // clear activation tokens, and force password reset on next login. + $tempHash = password_hash('changeme', PASSWORD_DEFAULT); + $stmt = $pdo->prepare( + "UPDATE {$prefix}users SET password = ?, activation = '', requireReset = 1" + ); + $stmt->execute([$tempHash]); + $affected = $stmt->rowCount(); + $results[] = "All {$affected} user password(s) reset to temporary password (changeme) with forced reset"; + break; + + case 'reset_hits': + $pdo->exec("UPDATE {$prefix}content SET hits = 0"); + $results[] = 'Content hits reset to 0'; + break; + + case 'clear_versions': + try { + $pdo->exec("TRUNCATE TABLE {$prefix}history"); + $results[] = 'Content version history cleared'; + } catch (PDOException $e) { + $results[] = 'Version history: table not found (skipped)'; + } + break; + + case 'clear_sessions': + $pdo->exec("TRUNCATE TABLE {$prefix}session"); + $results[] = 'Sessions cleared'; + break; + + case 'clear_cache': + // Clear Joomla cache tables + foreach (['cache', 'cache_extension'] as $tbl) { + try { + $pdo->exec("TRUNCATE TABLE {$prefix}{$tbl}"); + } catch (PDOException $e) { + // Table may not exist + } + } + + // Delete files in cache/ directory + $cacheDir = RESTORE_DIR . '/cache'; + $cacheCount = 0; + + if (is_dir($cacheDir)) { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($cacheDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $item) { + if ($item->isFile()) { + @unlink($item->getPathname()); + $cacheCount++; + } elseif ($item->isDir()) { + @rmdir($item->getPathname()); + } + } + } + + // Also clear administrator/cache/ + $adminCacheDir = RESTORE_DIR . '/administrator/cache'; + + if (is_dir($adminCacheDir)) { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($adminCacheDir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($it as $item) { + if ($item->isFile()) { + @unlink($item->getPathname()); + $cacheCount++; + } elseif ($item->isDir()) { + @rmdir($item->getPathname()); + } + } + } + + $results[] = "Cache tables cleared, {$cacheCount} cache file(s) removed"; + break; + + default: + $results[] = "Unknown task: {$task}"; + } + } catch (Throwable $e) { + $results[] = "Error ({$task}): " . $e->getMessage(); + } + } + + return ['success' => true, 'results' => $results, 'message' => count($results) . ' post-restore task(s) completed']; +} + +/** + * Detect whether the database contains sanitized sentinel password hashes. + * Returns true if any user has the MokoSuiteBackup sanitized placeholder hash. + */ +function detectSanitizedPasswords(array $data): array +{ + $pdo = getDbConnection($data); + $prefix = getValidatedPrefix($data); + $sentinel = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000'; + + $stmt = $pdo->prepare("SELECT COUNT(*) FROM {$prefix}users WHERE password = ?"); + $stmt->execute([$sentinel]); + $count = (int) $stmt->fetchColumn(); + + return ['success' => true, 'detected' => $count > 0, 'count' => $count]; +} + function actionProvision(array $data): array { $pdo = getDbConnection($data); @@ -1233,11 +1477,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1Checks
2Extract
-
3Database
-
4Configuration
-
5Admin
-
6Provisioning
-
7Complete
+
3Tables
+
4Database
+
5Configuration
+
6Admin
+
7Post-Restore
+
8Provisioning
+
9Complete
@@ -1292,8 +1538,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N - +
+

Table Conflict Resolution

+

Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.

+
+ + + + +
+
+ Modes: + Replace = drop + recreate + insert (default). + Skip = ignore entirely. + Merge = keep existing table, INSERT IGNORE new rows. + Data Only = keep schema, INSERT data as-is (assumes matching structure). +
+
+
Scanning tables...
+
+ +
+
+ + +
+
+ + +

Database Configuration

Enter the database credentials for this server. The SQL dump will be imported.

@@ -1312,13 +1586,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- -
+ +

Site Configuration

Configure your site settings. Credentials were removed from the backup for security — enter the correct values for this server.

@@ -1361,13 +1635,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- -
+ +

Super Admin Password

Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.

@@ -1380,16 +1654,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- +
- -
+ +
+

Post-Restore Actions

+

Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.

+ +
    +
  • Reset all user passwordsSet to "changeme" and force reset on next login
  • +
  • Reset content hitsSet all article hit counters to 0
  • +
  • Clear version historyTruncate the content version history table
  • +
  • Clear sessionsRemove all active user sessions
  • +
  • Clear cacheTruncate cache tables and delete cache files
  • +
+
+
+ +
+ + +
+
+
+ + +

Client Provisioning

Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.

    @@ -1404,16 +1702,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
- +
- +
- -
+ +

Installation Complete

Your Joomla site has been restored and configured.

@@ -1484,7 +1782,9 @@ function goStep(n) { else if (sn < n) s.classList.add('done'); }); - if (n === 5) loadAdmins(); + if (n === 3) scanTables(); + if (n === 6) loadAdmins(); + if (n === 7) checkSanitizedPasswords(); } function setStatus(id, msg, type) { @@ -1621,7 +1921,111 @@ async function runExtract() { } } -// Step 3 +// Step 3: Table Conflict Resolution +let tableList = []; + +async function scanTables() { + const container = document.getElementById('tableResolutionList'); + + // Only scan once + if (tableList.length > 0) return; + + log('Scanning database.sql for table names...'); + const r = await post('scanTables'); + + if (!r.success || !r.tables || r.tables.length === 0) { + container.innerHTML = '
No tables found in database.sql (or file not present). You can skip this step.
'; + setStatus('tableScanStatus', r.tables ? 'No tables found' : (r.message || 'Scan failed'), r.success ? '' : 'error'); + log(r.message || 'No tables found'); + return; + } + + tableList = r.tables; + log('Found ' + r.count + ' tables'); + setStatus('tableScanStatus', 'Found ' + r.count + ' tables', 'success'); + + renderTableList(); +} + +function renderTableList() { + const container = document.getElementById('tableResolutionList'); + container.innerHTML = ''; + + var resolutions = {}; + + tableList.forEach(function(name) { + var row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:0.5rem 0.75rem;border-bottom:1px solid #f1f5f9;font-size:0.85rem;'; + + var label = document.createElement('span'); + label.style.cssText = 'font-family:monospace;color:#334155;word-break:break-all;flex:1;margin-right:0.75rem;'; + label.textContent = name; + + var sel = document.createElement('select'); + sel.dataset.table = name; + sel.className = 'table-mode-select'; + sel.style.cssText = 'padding:0.3rem 0.5rem;border:1px solid #d1d5db;border-radius:4px;font-size:0.8rem;min-width:120px;background:#fff;'; + + var modes = [ + ['replace', 'Replace'], + ['skip', 'Skip'], + ['merge', 'Merge'], + ['dataonly', 'Data Only'] + ]; + + modes.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m[0]; + opt.textContent = m[1]; + sel.appendChild(opt); + }); + + sel.addEventListener('change', updateTableResolutions); + + row.appendChild(label); + row.appendChild(sel); + container.appendChild(row); + + resolutions[name] = 'replace'; + }); + + document.getElementById('tableResolutions').value = JSON.stringify(resolutions); +} + +function updateTableResolutions() { + var resolutions = {}; + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + resolutions[sel.dataset.table] = sel.value; + }); + document.getElementById('tableResolutions').value = JSON.stringify(resolutions); +} + +function setAllTableMode(mode) { + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + sel.value = mode; + }); + updateTableResolutions(); + log('Set all tables to: ' + mode); +} + +function presetExceptUsers() { + var userTables = ['#__users', '#__user_usergroup_map', '#__user_profiles']; + + document.querySelectorAll('.table-mode-select').forEach(function(sel) { + var tableName = sel.dataset.table; + + if (userTables.indexOf(tableName) !== -1) { + sel.value = 'skip'; + } else { + sel.value = 'replace'; + } + }); + + updateTableResolutions(); + log('Preset: Replace all except user tables (skipped)'); +} + +// Step 4 function getDbParams() { return { db_host: document.getElementById('dbHost').value, @@ -1647,7 +2051,12 @@ async function runDatabase() { log('Importing database...'); dbConfig = getDbParams(); - const r = await post('database', dbConfig); + // Include table conflict resolution selections + var tableRes = document.getElementById('tableResolutions'); + var dbParams = Object.assign({}, dbConfig, { + table_resolutions: tableRes ? tableRes.value : '{}' + }); + const r = await post('database', dbParams); document.getElementById('dbProgress').style.width = '100%'; setBtnLoading(btn, false); @@ -1655,17 +2064,20 @@ async function runDatabase() { if (r.success) { setStatus('dbStatus', r.message, 'success'); log(r.message); + if (r.skipped && r.skipped > 0) { + log(' Skipped ' + r.skipped + ' statements due to conflict resolution'); + } if (r.errorList && r.errorList.length > 0) { r.errorList.forEach(function(e) { log(' Warning: ' + e); }); } - setTimeout(function() { goStep(4); }, 500); + setTimeout(function() { goStep(5); }, 500); } else { setStatus('dbStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 4 +// Step 5 async function runConfig() { const btn = document.getElementById('btnConfig'); setBtnLoading(btn, true); @@ -1686,14 +2098,14 @@ async function runConfig() { if (r.success) { setStatus('configStatus', r.message, 'success'); log(r.message); - setTimeout(function() { goStep(5); }, 500); + setTimeout(function() { goStep(6); }, 500); } else { setStatus('configStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 5 +// Step 6 async function loadAdmins() { const sel = document.getElementById('adminSelect'); while (sel.firstChild) sel.removeChild(sel.firstChild); @@ -1738,20 +2150,65 @@ async function runResetAdmin() { if (r.success) { setStatus('adminStatus', r.message, 'success'); log(r.message); - setTimeout(function() { goStep(6); }, 500); + setTimeout(function() { goStep(7); }, 500); } else { setStatus('adminStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 6 +// Step 7: Post-Restore +async function checkSanitizedPasswords() { + log('Checking for sanitized password hashes...'); + + try { + const r = await post('detectSanitized', dbConfig); + + if (r.success && r.detected) { + document.getElementById('postRestoreSanitizedWarn').style.display = ''; + document.getElementById('prResetPasswords').checked = true; + log('WARNING: ' + r.count + ' user(s) have sanitized placeholder passwords'); + } else { + document.getElementById('postRestoreSanitizedWarn').style.display = 'none'; + log('No sanitized passwords detected'); + } + } catch (e) { + log('Could not check for sanitized passwords: ' + e.message); + } +} + +async function runPostRestore() { + const btn = document.getElementById('btnPostRestore'); + const tasks = []; + document.querySelectorAll('.post-restore-task:checked').forEach(function(cb) { tasks.push(cb.value); }); + + if (tasks.length === 0) { goStep(8); return; } + + setBtnLoading(btn, true); + log('Running ' + tasks.length + ' post-restore task(s)...'); + + const params = Object.assign({}, dbConfig, { tasks: JSON.stringify(tasks) }); + const r = await post('postRestore', params); + + setBtnLoading(btn, false); + + if (r.success) { + setStatus('postRestoreStatus', r.message, 'success'); + r.results.forEach(function(msg) { log(' ' + msg); }); + setTimeout(function() { goStep(8); }, 500); + } else { + setStatus('postRestoreStatus', r.message, 'error'); + log('FAILED: ' + r.message); + } +} + +// Step 8 async function runProvision() { const btn = document.getElementById('btnProvision'); const tasks = []; document.querySelectorAll('.prov-task:checked').forEach(function(cb) { tasks.push(cb.value); }); - if (tasks.length === 0) { goStep(7); return; } + if (tasks.length === 0) { goStep(9); return; } setBtnLoading(btn, true); log('Running ' + tasks.length + ' provisioning tasks...'); @@ -1764,14 +2221,14 @@ async function runProvision() { if (r.success) { setStatus('provisionStatus', r.message, 'success'); r.results.forEach(function(msg) { log(' ' + msg); }); - setTimeout(function() { goStep(7); }, 500); + setTimeout(function() { goStep(9); }, 500); } else { setStatus('provisionStatus', r.message, 'error'); log('FAILED: ' + r.message); } } -// Step 7 +// Step 9 async function runCleanup() { log('Cleaning up restore files...'); const r = await post('cleanup');