diff --git a/admin/post/backup.php b/admin/post/backup.php index eac494cd..6abe4c67 100644 --- a/admin/post/backup.php +++ b/admin/post/backup.php @@ -2,185 +2,304 @@ /* * ITFlow - GET/POST request handler for DB / master key backup + * Rewritten with streaming SQL dump, component checksums, safer zipping, and better headers. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); require_once "../includes/app_version.php"; -if (isset($_GET['download_backup'])) { - - validateCSRFToken($_GET['csrf_token']); - - $timestamp = date('YmdHis'); - $baseName = "itflow_$timestamp"; +// --- Optional performance levers for big backups --- +@set_time_limit(0); +if (function_exists('ini_set')) { + @ini_set('memory_limit', '1024M'); +} - // === 0. Scoped cleanup === - $cleanupFiles = []; +/** + * Write a line to a file handle with newline. + */ +function fwrite_ln($fh, string $s): void { + fwrite($fh, $s); + fwrite($fh, PHP_EOL); +} - $registerTempFileForCleanup = function ($file) use (&$cleanupFiles) { - $cleanupFiles[] = $file; - }; - - register_shutdown_function(function () use (&$cleanupFiles) { - foreach ($cleanupFiles as $file) { - if (is_file($file)) { - @unlink($file); - } - } - }); - - // === 1. Local helper function: zipFolder - $zipFolder = function ($folderPath, $zipFilePath) { - $zip = new ZipArchive(); - if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) { - error_log("Failed to open zip file: $zipFilePath"); - http_response_code(500); - exit("Internal Server Error: Cannot open zip archive."); - } - - $folderPath = realpath($folderPath); - if (!$folderPath) { - error_log("Invalid folder path: $folderPath"); - http_response_code(500); - exit("Internal Server Error: Invalid folder path."); - } - - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($folderPath), - RecursiveIteratorIterator::LEAVES_ONLY - ); - - foreach ($files as $file) { - if (!$file->isDir()) { - $filePath = $file->getRealPath(); - $relativePath = substr($filePath, strlen($folderPath) + 1); - $zip->addFile($filePath, $relativePath); - } - } - - $zip->close(); - }; - - // === 2. Create all temp files - $sqlFile = tempnam(sys_get_temp_dir(), $baseName . "_sql_"); - $uploadsZip = tempnam(sys_get_temp_dir(), $baseName . "_uploads_"); - $versionFile = tempnam(sys_get_temp_dir(), $baseName . "_version_"); - $finalZip = tempnam(sys_get_temp_dir(), $baseName . "_backup_"); - - foreach ([$sqlFile, $uploadsZip, $versionFile, $finalZip] as $f) { - $registerTempFileForCleanup($f); - chmod($f, 0600); +/** + * Stream a SQL dump of schema and data into $sqlFile. + * - Tables first (DROP + CREATE + INSERTs) + * - Views (DROP VIEW + CREATE VIEW) + * - Triggers (DROP TRIGGER + CREATE TRIGGER) + * + * NOTE: Routines/events are not dumped here. Add if needed. + */ +function dump_database_streaming(mysqli $mysqli, string $sqlFile): void { + $fh = fopen($sqlFile, 'wb'); + if (!$fh) { + http_response_code(500); + exit("Cannot open dump file"); } - // === 3. Generate SQL Dump - $sqlContent = "-- UTF-8 + Foreign Key Safe Dump\n"; - $sqlContent .= "SET NAMES 'utf8mb4';\n"; - $sqlContent .= "SET foreign_key_checks = 0;\n\n"; + // Preamble + fwrite_ln($fh, "-- UTF-8 + Foreign Key Safe Dump"); + fwrite_ln($fh, "SET NAMES 'utf8mb4';"); + fwrite_ln($fh, "SET FOREIGN_KEY_CHECKS = 0;"); + fwrite_ln($fh, "SET UNIQUE_CHECKS = 0;"); + fwrite_ln($fh, "SET AUTOCOMMIT = 0;"); + fwrite_ln($fh, ""); + // Gather tables and views $tables = []; - $res = $mysqli->query("SHOW TABLES"); + $views = []; + + $res = $mysqli->query("SHOW FULL TABLES"); if (!$res) { - error_log("MySQL Error: " . $mysqli->error); + fclose($fh); + error_log("MySQL Error (SHOW FULL TABLES): " . $mysqli->error); + http_response_code(500); exit("Error retrieving tables."); } + while ($row = $res->fetch_array(MYSQLI_NUM)) { + $name = $row[0]; + $type = strtoupper($row[1] ?? ''); + if ($type === 'VIEW') { + $views[] = $name; + } else { + $tables[] = $name; + } + } + $res->close(); - while ($row = $res->fetch_row()) { - $tables[] = $row[0]; + // --- TABLES: structure and data --- + foreach ($tables as $table) { + $createRes = $mysqli->query("SHOW CREATE TABLE `{$mysqli->real_escape_string($table)}`"); + if (!$createRes) { + error_log("MySQL Error (SHOW CREATE TABLE $table): " . $mysqli->error); + // continue to next table + continue; + } + $createRow = $createRes->fetch_assoc(); + $createSQL = array_values($createRow)[1] ?? ''; + $createRes->close(); + + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "-- Table structure for `{$table}`"); + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "DROP TABLE IF EXISTS `{$table}`;"); + fwrite_ln($fh, $createSQL . ";"); + fwrite_ln($fh, ""); + + // Dump data in a streaming fashion + $dataRes = $mysqli->query("SELECT * FROM `{$mysqli->real_escape_string($table)}`", MYSQLI_USE_RESULT); + if ($dataRes) { + $wroteHeader = false; + while ($row = $dataRes->fetch_assoc()) { + if (!$wroteHeader) { + fwrite_ln($fh, "-- Dumping data for table `{$table}`"); + $wroteHeader = true; + } + $cols = array_map(fn($c) => '`' . $mysqli->real_escape_string($c) . '`', array_keys($row)); + $vals = array_map( + function ($v) use ($mysqli) { + return is_null($v) ? "NULL" : "'" . $mysqli->real_escape_string($v) . "'"; + }, + array_values($row) + ); + fwrite_ln($fh, "INSERT INTO `{$table}` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");"); + } + $dataRes->close(); + if ($wroteHeader) fwrite_ln($fh, ""); + } } - foreach ($tables as $table) { - $createRes = $mysqli->query("SHOW CREATE TABLE `$table`"); - if (!$createRes) { - error_log("MySQL Error: " . $mysqli->error); + // --- VIEWS --- + foreach ($views as $view) { + $escView = $mysqli->real_escape_string($view); + $cRes = $mysqli->query("SHOW CREATE VIEW `{$escView}`"); + if ($cRes) { + $row = $cRes->fetch_assoc(); + $createView = $row['Create View'] ?? ''; + $cRes->close(); + + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "-- View structure for `{$view}`"); + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "DROP VIEW IF EXISTS `{$view}`;"); + // Ensure statement ends with semicolon + if (!str_ends_with($createView, ';')) $createView .= ';'; + fwrite_ln($fh, $createView); + fwrite_ln($fh, ""); + } + } + + // --- TRIGGERS --- + $tRes = $mysqli->query("SHOW TRIGGERS"); + if ($tRes) { + while ($t = $tRes->fetch_assoc()) { + $triggerName = $t['Trigger']; + $escTrig = $mysqli->real_escape_string($triggerName); + $crt = $mysqli->query("SHOW CREATE TRIGGER `{$escTrig}`"); + if ($crt) { + $row = $crt->fetch_assoc(); + $createTrig = $row['SQL Original Statement'] ?? ($row['Create Trigger'] ?? ''); + $crt->close(); + + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "-- Trigger for `{$triggerName}`"); + fwrite_ln($fh, "-- ----------------------------"); + fwrite_ln($fh, "DROP TRIGGER IF EXISTS `{$triggerName}`;"); + if (!str_ends_with($createTrig, ';')) $createTrig .= ';'; + fwrite_ln($fh, $createTrig); + fwrite_ln($fh, ""); + } + } + $tRes->close(); + } + + // Postamble + fwrite_ln($fh, "SET FOREIGN_KEY_CHECKS = 1;"); + fwrite_ln($fh, "SET UNIQUE_CHECKS = 1;"); + fwrite_ln($fh, "COMMIT;"); + + fclose($fh); +} + +/** + * Zip a folder to $zipFilePath, skipping symlinks and dot-entries. + */ +function zipFolderStrict(string $folderPath, string $zipFilePath): void { + $zip = new ZipArchive(); + if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) { + error_log("Failed to open zip file: $zipFilePath"); + http_response_code(500); + exit("Internal Server Error: Cannot open zip archive."); + } + + $folderReal = realpath($folderPath); + if (!$folderReal || !is_dir($folderReal)) { + // Create an empty archive if uploads folder doesn't exist yet + $zip->close(); + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($folderReal, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + /** @var SplFileInfo $file */ + if ($file->isDir()) continue; + if ($file->isLink()) continue; // skip symlinks + $filePath = $file->getRealPath(); + if ($filePath === false) continue; + + // ensure path is inside the folder boundary + if (strpos($filePath, $folderReal . DIRECTORY_SEPARATOR) !== 0 && $filePath !== $folderReal) { continue; } - $createRow = $createRes->fetch_assoc(); - $createSQL = array_values($createRow)[1]; - - $sqlContent .= "\n-- ----------------------------\n"; - $sqlContent .= "-- Table structure for `$table`\n"; - $sqlContent .= "-- ----------------------------\n"; - $sqlContent .= "DROP TABLE IF EXISTS `$table`;\n"; - $sqlContent .= $createSQL . ";\n\n"; - - $dataRes = $mysqli->query("SELECT * FROM `$table`"); - if ($dataRes && $dataRes->num_rows > 0) { - $sqlContent .= "-- Dumping data for table `$table`\n"; - while ($row = $dataRes->fetch_assoc()) { - $columns = array_map(fn($col) => '`' . $mysqli->real_escape_string($col) . '`', array_keys($row)); - $values = array_map(function ($val) use ($mysqli) { - return is_null($val) ? "NULL" : "'" . $mysqli->real_escape_string($val) . "'"; - }, array_values($row)); - $sqlContent .= "INSERT INTO `$table` (" . implode(", ", $columns) . ") VALUES (" . implode(", ", $values) . ");\n"; - } - $sqlContent .= "\n"; - } + $relativePath = substr($filePath, strlen($folderReal) + 1); + $zip->addFile($filePath, $relativePath); } - $sqlContent .= "SET foreign_key_checks = 1;\n"; - file_put_contents($sqlFile, $sqlContent); + $zip->close(); +} - // === 4. Zip the uploads folder - $zipFolder("../uploads", $uploadsZip); +if (isset($_GET['download_backup'])) { - // === 5. Create version.txt - $commitHash = trim(shell_exec('git log -1 --format=%H')) ?: 'N/A'; - $gitBranch = trim(shell_exec('git rev-parse --abbrev-ref HEAD')) ?: 'N/A'; + validateCSRFToken($_GET['csrf_token']); - $versionContent = "ITFlow Backup Metadata\n"; + $timestamp = date('YmdHis'); + $baseName = "itflow_{$timestamp}"; + $downloadName = $baseName . ".zip"; + + // === Scoped cleanup of temp files === + $cleanupFiles = []; + $registerTempFileForCleanup = function ($file) use (&$cleanupFiles) { + $cleanupFiles[] = $file; + }; + register_shutdown_function(function () use (&$cleanupFiles) { + foreach ($cleanupFiles as $file) { + if (is_file($file)) { @unlink($file); } + } + }); + + // === Create temp files === + $sqlFile = tempnam(sys_get_temp_dir(), $baseName . "_sql_"); + $uploadsZip = tempnam(sys_get_temp_dir(), $baseName . "_uploads_"); + $versionFile = tempnam(sys_get_temp_dir(), $baseName . "_version_"); + $finalZip = tempnam(sys_get_temp_dir(), $baseName . "_backup_"); + + foreach ([$sqlFile, $uploadsZip, $versionFile, $finalZip] as $f) { + $registerTempFileForCleanup($f); + @chmod($f, 0600); + } + + // === Generate SQL Dump (streaming) === + dump_database_streaming($mysqli, $sqlFile); + + // === Zip the uploads folder (strict) === + zipFolderStrict("../uploads", $uploadsZip); + + // === Gather metadata & checksums === + $commitHash = (function_exists('shell_exec') ? trim(shell_exec('git log -1 --format=%H 2>/dev/null')) : '') ?: 'N/A'; + $gitBranch = (function_exists('shell_exec') ? trim(shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')) : '') ?: 'N/A'; + + $dbSha = hash_file('sha256', $sqlFile) ?: 'N/A'; + $upSha = hash_file('sha256', $uploadsZip) ?: 'N/A'; + + $versionContent = "ITFlow Backup Metadata\n"; $versionContent .= "-----------------------------\n"; $versionContent .= "Generated: " . date('Y-m-d H:i:s') . "\n"; - $versionContent .= "Backup File: " . basename($finalZip) . "\n"; - $versionContent .= "Generated By: $session_name\n"; + $versionContent .= "Backup File: " . $downloadName . "\n"; + $versionContent .= "Generated By: " . ($session_name ?? 'Unknown User') . "\n"; $versionContent .= "Host: " . gethostname() . "\n"; $versionContent .= "Git Branch: $gitBranch\n"; $versionContent .= "Git Commit: $commitHash\n"; $versionContent .= "ITFlow Version: " . (defined('APP_VERSION') ? APP_VERSION : 'Unknown') . "\n"; $versionContent .= "Database Version: " . (defined('CURRENT_DATABASE_VERSION') ? CURRENT_DATABASE_VERSION : 'Unknown') . "\n"; - $versionContent .= "Checksum (SHA256): \n"; + $versionContent .= "Checksums (SHA256):\n"; + $versionContent .= " db.sql: $dbSha\n"; + $versionContent .= " uploads.zip: $upSha\n"; file_put_contents($versionFile, $versionContent); + @chmod($versionFile, 0600); - // === 6. Build final ZIP + // === Build final ZIP === $final = new ZipArchive(); if ($final->open($finalZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) { error_log("Failed to create final zip: $finalZip"); http_response_code(500); exit("Internal Server Error: Unable to create backup archive."); } - $final->addFile($sqlFile, "db.sql"); $final->addFile($uploadsZip, "uploads.zip"); $final->addFile($versionFile, "version.txt"); $final->close(); - chmod($finalZip, 0600); + @chmod($finalZip, 0600); - $checksum = hash_file('sha256', $finalZip); - file_put_contents($versionFile, $versionContent . "$checksum\n"); - - // === 7. Serve final ZIP + // === Serve final ZIP with a stable filename === header('Content-Type: application/zip'); - header('Content-Disposition: attachment; filename="' . basename($finalZip) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Content-Disposition: attachment; filename="' . $downloadName . '"'); header('Content-Length: ' . filesize($finalZip)); header('Pragma: public'); header('Expires: 0'); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Content-Transfer-Encoding: binary'); + // Push file flush(); $fp = fopen($finalZip, 'rb'); fpassthru($fp); fclose($fp); - logAction("System", "Backup Download", "$session_name downloaded full backup."); + // Log + UX + logAction("System", "Backup Download", ($session_name ?? 'Unknown User') . " downloaded full backup."); flash_alert("Full backup downloaded."); exit; } - if (isset($_POST['backup_master_key'])) { validateCSRFToken($_POST['csrf_token']); diff --git a/setup/index.php b/setup/index.php index bd30655c..112f402f 100644 --- a/setup/index.php +++ b/setup/index.php @@ -5,7 +5,8 @@ if (file_exists("../config.php")) { } -include "../functions.php"; +include "../functions.php"; // Global Functions +include "setup_functions.php"; // Setup Only Functions include "../includes/database_version.php"; if (!isset($config_enable_setup)) { @@ -127,134 +128,220 @@ if (isset($_POST['add_database'])) { if (isset($_POST['restore'])) { - // === 1. Validate uploaded file === + // --- CSRF check (add a token to the form; see form snippet below) --- + if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { + http_response_code(403); + exit("Invalid CSRF token."); + } + + // --- Basic env guards for long operations --- + @set_time_limit(0); + if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); } + + // --- 1) Validate uploaded file --- if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) { die("No backup file uploaded or upload failed."); } - $file = $_FILES['backup_zip']; - $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); - if ($fileExt !== "zip") { + + // Size limit (e.g., 4 GB) + if ($file['size'] > 4 * 1024 * 1024 * 1024) { + die("Backup archive is too large."); + } + + // MIME check + $fi = new finfo(FILEINFO_MIME_TYPE); + $mime = $fi->file($file['tmp_name']); + if ($mime !== 'application/zip' && $mime !== 'application/x-zip-compressed') { + die("Invalid archive type; only .zip is supported."); + } + + // Extension check (defense in depth) + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($ext !== 'zip') { die("Only .zip files are allowed."); } - // === 2. Move to secure temp location === - $tempZip = tempnam(sys_get_temp_dir(), "restore_"); + // --- 2) Move to secure temp location --- + $timestamp = date('YmdHis'); + $tempZip = tempnam(sys_get_temp_dir(), "restore_{$timestamp}_"); if (!move_uploaded_file($file["tmp_name"], $tempZip)) { die("Failed to save uploaded backup file."); } + @chmod($tempZip, 0600); + + // --- 3) Extract safely to unique temp dir --- + $tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6)); + if (!mkdir($tempDir, 0700, true)) { + @unlink($tempZip); + die("Failed to create temp directory."); + } $zip = new ZipArchive; if ($zip->open($tempZip) !== TRUE) { - unlink($tempZip); + @unlink($tempZip); + deleteDir($tempDir); die("Failed to open backup zip file."); } - // === 3. Zip-slip protection and extract to unique dir === - $tempDir = sys_get_temp_dir() . "/restore_temp_" . uniqid(); - mkdir($tempDir, 0700, true); - - for ($i = 0; $i < $zip->numFiles; $i++) { - $stat = $zip->statIndex($i); - if (strpos($stat['name'], '..') !== false) { - $zip->close(); - unlink($tempZip); - die("Invalid file path in ZIP."); - } - } - - if (!$zip->extractTo($tempDir)) { + try { + safeExtractZip($zip, $tempDir); + } catch (Throwable $e) { $zip->close(); - unlink($tempZip); - die("Failed to extract backup contents."); + @unlink($tempZip); + deleteDir($tempDir); + die("Invalid backup archive: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - $zip->close(); - unlink($tempZip); + @unlink($tempZip); - // === 4. Restore SQL === - $sqlPath = "$tempDir/db.sql"; - if (file_exists($sqlPath)) { - mysqli_query($mysqli, "SET foreign_key_checks = 0"); - $tables = mysqli_query($mysqli, "SHOW TABLES"); - while ($row = mysqli_fetch_array($tables)) { - mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $row[0] . "`"); - } - mysqli_query($mysqli, "SET foreign_key_checks = 1"); + // Paths inside extracted archive + $sqlPath = $tempDir . "/db.sql"; + $uploadsZip = $tempDir . "/uploads.zip"; + $versionTxt = $tempDir . "/version.txt"; - // Use env var to avoid exposing password - putenv("MYSQL_PWD=$dbpassword"); - $command = sprintf( - 'mysql -h%s -u%s %s < %s', - escapeshellarg($dbhost), - escapeshellarg($dbusername), - escapeshellarg($database), - escapeshellarg($sqlPath) - ); - - exec($command, $output, $returnCode); - if ($returnCode !== 0) { - deleteDir($tempDir); - die("SQL import failed. Error code: $returnCode"); - } - } else { + if (!is_file($sqlPath) || !is_readable($sqlPath)) { deleteDir($tempDir); die("Missing db.sql in the backup archive."); } - - // === 5. Restore uploads directory === - $uploadDir = __DIR__ . "/../uploads/"; - $uploadsZip = "$tempDir/uploads.zip"; - - if (file_exists($uploadsZip)) { - $uploads = new ZipArchive; - if ($uploads->open($uploadsZip) === TRUE) { - // Clean existing uploads - foreach (new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ) as $item) { - $item->isDir() ? rmdir($item) : unlink($item); - } - - $uploads->extractTo($uploadDir); - $uploads->close(); - } else { - deleteDir($tempDir); - die("Failed to open uploads.zip in backup."); - } - } else { + if (!is_file($uploadsZip) || !is_readable($uploadsZip)) { deleteDir($tempDir); die("Missing uploads.zip in the backup archive."); } - // === 6. Read version.txt (optional display/logging) === - $versionTxt = "$tempDir/version.txt"; - if (file_exists($versionTxt)) { - $versionInfo = file_get_contents($versionTxt); - logAction("Backup Restore", "Version Info", $versionInfo); + // --- 4) Optional: check version compatibility --- + if (defined('LATEST_DATABASE_VERSION') && is_file($versionTxt)) { + $txt = @file_get_contents($versionTxt) ?: ''; + // Try to find line "Database Version: X" + if (preg_match('/^Database Version:\s*(.+)$/mi', $txt, $m)) { + $backupVersion = trim($m[1]); + $running = LATEST_DATABASE_VERSION; + // If backup schema is newer, abort with instruction + if (version_compare($backupVersion, $running, '>')) { + deleteDir($tempDir); + die("Backup schema ($backupVersion) is newer than this app ($running). Please upgrade ITFlow first, then retry restore."); + } + } } - // === 7. Clean up temp dir === - function deleteDir($dir) { - if (!is_dir($dir)) return; - $items = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST - ); - foreach ($items as $item) { - $item->isDir() ? rmdir($item) : unlink($item); + // --- 5) Restore SQL (drop + import) --- + // Drop all tables + mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0"); + $tables = mysqli_query($mysqli, "SHOW TABLES"); + if ($tables) { + while ($row = mysqli_fetch_array($tables)) { + $tbl = $row[0]; + mysqli_query($mysqli, "DROP TABLE IF EXISTS `".$mysqli->real_escape_string($tbl)."`"); } - rmdir($dir); } + mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1"); + + try { + importSqlFile($mysqli, $sqlPath); + } catch (Throwable $e) { + deleteDir($tempDir); + die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + } + + // --- 6) Restore uploads via staging + atomic swap --- + $appRoot = realpath(__DIR__ . "/.."); + $uploadDir = realpath($appRoot . "/uploads"); + if ($uploadDir === false) { + // uploads might not exist yet + $uploadDir = $appRoot . "/uploads"; + if (!mkdir($uploadDir, 0750, true)) { + deleteDir($tempDir); + die("Failed to create uploads directory."); + } + $uploadDir = realpath($uploadDir); + } + + if ($uploadDir === false || str_starts_with($uploadDir, $appRoot) === false) { + deleteDir($tempDir); + die("Uploads directory path invalid."); + } + + $staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4)); + if (!mkdir($staging, 0700, true)) { + deleteDir($tempDir); + die("Failed to create staging directory."); + } + + $uz = new ZipArchive; + if ($uz->open($uploadsZip) !== TRUE) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to open uploads.zip in backup."); + } + + // IMPORTANT: staging dir should be empty here (as in your existing flow) + $result = extractUploadsZipWithValidationReport($uz, $staging, [ + 'max_file_bytes' => 200 * 1024 * 1024, // adjust per-file size cap + 'blocked_exts' => [ + 'php','php3','php4','php5','php7','php8','phtml','phar', + 'cgi','pl','sh','bash','zsh','exe','dll','bat','cmd','com', + 'ps1','vbs','vb','jar','jsp','asp','aspx','so','dylib','bin' + ], + ]); + $uz->close(); + + if (!$result['ok']) { + // Build a user-friendly report + $lines = ["Unsafe file(s) detected in uploads.zip:"]; + foreach ($result['issues'] as $issue) { + $p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8'); + $r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8'); + $lines[] = "• {$p} — {$r}"; + } + + // Clean staging and temp and show the report + deleteDir($staging); + deleteDir($tempDir); + + $_SESSION['alert_message'] = nl2br(implode("\n", $lines)); + header("Location: ?restore"); + exit; + + } + + // Rotate old uploads out, promote staging in + $backupOld = $appRoot . "/uploads_old_" . time(); + if (!rename($uploadDir, $backupOld)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to rotate old uploads."); + } + if (!rename($staging, $uploadDir)) { + // try to revert + @rename($backupOld, $uploadDir); + deleteDir($tempDir); + die("Failed to promote restored uploads."); + } + // Optional: clean old uploads now or keep briefly for rollback + // deleteDir($backupOld); + + // --- 7) Log version info (optional) --- + if (is_file($versionTxt)) { + $versionInfo = @file_get_contents($versionTxt); + if ($versionInfo !== false) { + logAction("Backup Restore", "Version Info", $versionInfo); + } + } + + // --- 8) Cleanup temp dir --- deleteDir($tempDir); - // === 8. Optional: finalize setup flag === - $myfile = fopen("../config.php", "a"); - fwrite($myfile, "\$config_enable_setup = 0;\n\n"); - fclose($myfile); + // --- 9) Finalize setup flag (idempotent) --- + try { + setConfigFlag("../config.php", "config_enable_setup", 0); + } catch (Throwable $e) { + // Non-fatal; warn but continue to login + $_SESSION['alert_message'] = "Backup restored, but failed to finalize setup flag in config.php: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); + header("Location: ../login.php"); + exit; + } - // === 9. Done === + // --- 10) Done --- $_SESSION['alert_message'] = "Full backup restored successfully."; header("Location: ../login.php"); exit; @@ -1109,9 +1196,15 @@ if (isset($_POST['add_telemetry'])) {