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
Choose how each table should be handled during database import. This lets you protect specific tables (e.g. users) from being overwritten.
+Enter the database credentials for this server. The SQL dump will be imported.
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 NReset the password for a super administrator account. This is optional but recommended after restoring to a new server.
Optional reset tasks to clean up the restored database. These are especially useful when restoring a sanitized backup.
+ +Optional cleanup tasks for deploying this backup as a new client site. Check the tasks you want to run.
Your Joomla site has been restored and configured.