feat: MokoRestore post-restore resets + per-table conflict resolution (#131, #132)
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
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Failing after 13s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 53s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m0s

#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
This commit is contained in:
Jonathan Miller
2026-06-23 12:33:18 -05:00
parent f830dc2ddf
commit 0dc0eb1bef
2 changed files with 507 additions and 43 deletions
+7
View File
@@ -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
@@ -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
<div class="mr-steps" id="stepBar">
<div class="mr-step active" data-step="1"><span class="mr-num">1</span>Checks</div>
<div class="mr-step" data-step="2"><span class="mr-num">2</span>Extract</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Database</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Configuration</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Admin</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Provisioning</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Complete</div>
<div class="mr-step" data-step="3"><span class="mr-num">3</span>Tables</div>
<div class="mr-step" data-step="4"><span class="mr-num">4</span>Database</div>
<div class="mr-step" data-step="5"><span class="mr-num">5</span>Configuration</div>
<div class="mr-step" data-step="6"><span class="mr-num">6</span>Admin</div>
<div class="mr-step" data-step="7"><span class="mr-num">7</span>Post-Restore</div>
<div class="mr-step" data-step="8"><span class="mr-num">8</span>Provisioning</div>
<div class="mr-step" data-step="9"><span class="mr-num">9</span>Complete</div>
</div>
<!-- Step 0: Security Verification -->
@@ -1292,8 +1538,36 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
</div>
<!-- Step 3: Database -->
<!-- Step 3: Table Conflict Resolution -->
<div class="mr-panel" id="panel3">
<h2>Table Conflict Resolution</h2>
<p class="mr-desc">Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.</p>
<div style="margin-bottom:1rem;display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('replace')">All Replace</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('skip')">All Skip</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="setAllTableMode('merge')">All Merge</button>
<button class="mr-btn mr-btn-outline" style="font-size:0.8rem;padding:0.4rem 0.8rem" onclick="presetExceptUsers()">Everything except users</button>
</div>
<div class="mr-alert mr-alert-info" style="font-size:0.85rem">
<strong>Modes:</strong>
<strong>Replace</strong> = drop + recreate + insert (default).
<strong>Skip</strong> = ignore entirely.
<strong>Merge</strong> = keep existing table, INSERT IGNORE new rows.
<strong>Data Only</strong> = keep schema, INSERT data as-is (assumes matching structure).
</div>
<div id="tableResolutionList" style="max-height:400px;overflow-y:auto;border:1px solid #e2e8f0;border-radius:8px;margin-top:1rem">
<div style="padding:1rem;color:#94a3b8;text-align:center">Scanning tables...</div>
</div>
<input type="hidden" id="tableResolutions" value="{}">
<div class="mr-status" id="tableScanStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnTablesContinue" onclick="goStep(4)">Continue to Database</button>
</div>
</div>
<!-- Step 4: Database -->
<div class="mr-panel" id="panel4">
<h2>Database Configuration</h2>
<p class="mr-desc">Enter the database credentials for this server. The SQL dump will be imported.</p>
<div class="mr-row">
@@ -1312,13 +1586,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
<div class="mr-progress"><div class="mr-progress-bar" id="dbProgress" style="width:0%"></div></div>
<div class="mr-status" id="dbStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(2)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnImport" onclick="runDatabase()">Import Database</button>
</div>
</div>
<!-- Step 4: Site Configuration -->
<div class="mr-panel" id="panel4">
<!-- Step 5: Site Configuration -->
<div class="mr-panel" id="panel5">
<h2>Site Configuration</h2>
<p class="mr-desc">Configure your site settings. Credentials were removed from the backup for security &mdash; enter the correct values for this server.</p>
@@ -1361,13 +1635,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="configStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(3)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-primary" id="btnConfig" onclick="runConfig()">Save Configuration</button>
</div>
</div>
<!-- Step 5: Admin Password Reset -->
<div class="mr-panel" id="panel5">
<!-- Step 6: Admin Password Reset -->
<div class="mr-panel" id="panel6">
<h2>Super Admin Password</h2>
<p class="mr-desc">Reset the password for a super administrator account. This is optional but recommended after restoring to a new server.</p>
<div class="mr-field">
@@ -1380,16 +1654,40 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</div>
<div class="mr-status" id="adminStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(4)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnResetAdmin" onclick="runResetAdmin()">Reset Password</button>
</div>
</div>
</div>
<!-- Step 6: Client Provisioning -->
<div class="mr-panel" id="panel6">
<!-- Step 7: Post-Restore Actions -->
<div class="mr-panel" id="panel7">
<h2>Post-Restore Actions</h2>
<p class="mr-desc">Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.</p>
<div class="mr-alert mr-alert-warn" id="postRestoreSanitizedWarn" style="display:none">
<strong>Sanitized passwords detected!</strong> This backup contains placeholder password hashes that will prevent all users from logging in. The "Reset all user passwords" option below is strongly recommended.
</div>
<ul class="mr-provision-list" id="postRestoreList">
<li><input type="checkbox" class="post-restore-task" id="prResetPasswords" value="reset_passwords"><span>Reset all user passwords</span><span class="mr-provision-desc">Set to "changeme" and force reset on next login</span></li>
<li><input type="checkbox" class="post-restore-task" value="reset_hits"><span>Reset content hits</span><span class="mr-provision-desc">Set all article hit counters to 0</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_versions"><span>Clear version history</span><span class="mr-provision-desc">Truncate the content version history table</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_sessions" checked><span>Clear sessions</span><span class="mr-provision-desc">Remove all active user sessions</span></li>
<li><input type="checkbox" class="post-restore-task" value="clear_cache" checked><span>Clear cache</span><span class="mr-provision-desc">Truncate cache tables and delete cache files</span></li>
</ul>
<div class="mr-status" id="postRestoreStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(6)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(8)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnPostRestore" onclick="runPostRestore()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 8: Client Provisioning -->
<div class="mr-panel" id="panel8">
<h2>Client Provisioning</h2>
<p class="mr-desc">Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.</p>
<ul class="mr-provision-list">
@@ -1404,16 +1702,16 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
</ul>
<div class="mr-status" id="provisionStatus"></div>
<div class="mr-actions">
<button class="mr-btn mr-btn-outline" onclick="goStep(5)">Back</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Back</button>
<div>
<button class="mr-btn mr-btn-outline" onclick="goStep(7)">Skip</button>
<button class="mr-btn mr-btn-outline" onclick="goStep(9)">Skip</button>
<button class="mr-btn mr-btn-primary" id="btnProvision" onclick="runProvision()">Run Selected Tasks</button>
</div>
</div>
</div>
<!-- Step 7: Complete -->
<div class="mr-panel" id="panel7">
<!-- Step 9: Complete -->
<div class="mr-panel" id="panel9">
<h2>Installation Complete</h2>
<p class="mr-desc">Your Joomla site has been restored and configured.</p>
<div class="mr-alert mr-alert-success">
@@ -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 = '<div style="padding:1rem;color:#94a3b8;text-align:center">No tables found in database.sql (or file not present). You can skip this step.</div>';
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');