From ed589ef65b96e7f10a5007ab6a0fa6e09e00cfd2 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 12:28:38 -0400 Subject: [PATCH 001/112] Update Backup / Restore, now streams backup and restore to disk instead of memory causing memory to run out, sets timeout limit to unlimited, checks backup file contents for anything bad, use php instead shell exec for import of db, added .htaccess for apache to prevent php execution in /uploads/ directory as this is intended for file download only --- admin/post/backup.php | 349 +++++++++++++++++++++++++------------- setup/index.php | 283 ++++++++++++++++++++----------- setup/setup_functions.php | 347 +++++++++++++++++++++++++++++++++++++ uploads/.htaccess | 11 ++ 4 files changed, 780 insertions(+), 210 deletions(-) create mode 100644 setup/setup_functions.php create mode 100644 uploads/.htaccess 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'])) {

Restore from Backup

-
+ + + +

Large restores may take several minutes. Do not close this page.


- -

Large restores may take several minutes. Do not close this page.

From b7e0e5c5eb79a83b9d64732ad6796b9f2614876a Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 13:00:00 -0400 Subject: [PATCH 003/112] Fix setup complete flag --- setup/index.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/setup/index.php b/setup/index.php index 94d2f6dc..4846e981 100644 --- a/setup/index.php +++ b/setup/index.php @@ -325,15 +325,12 @@ if (isset($_POST['restore'])) { // --- 8) Cleanup temp dir --- deleteDir($tempDir); - // --- 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) Finalize setup flag --- + $myfile = fopen("../config.php", "a"); + $txt = "\$config_enable_setup = 0;\n\n"; + + fwrite($myfile, $txt); + fclose($myfile); // --- 10) Done --- $_SESSION['alert_message'] = "Full backup restored successfully."; From 2c534d4d20f8021ebfdc6a9c61df909609f391e3 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 18:10:21 -0400 Subject: [PATCH 004/112] Attempt to fix uploads and writing to config file during setup --- setup/index.php | 233 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 195 insertions(+), 38 deletions(-) diff --git a/setup/index.php b/setup/index.php index 4846e981..71868d0e 100644 --- a/setup/index.php +++ b/setup/index.php @@ -126,8 +126,127 @@ if (isset($_POST['add_database'])) { } +isFile()) $count++; + } + return $count; + } + } + + if (!function_exists('setConfigFlagAtomic')) { + function setConfigFlagAtomic(string $file, string $key, $value): void { + clearstatcache(true, $file); + if (!file_exists($file)) throw new RuntimeException("config.php not found: $file"); + if (!is_readable($file)) throw new RuntimeException("config.php not readable: $file"); + if (!is_writable($file)) throw new RuntimeException("config.php not writable: $file"); + + $cfg = file_get_contents($file); + if ($cfg === false) throw new RuntimeException("Failed to read config.php"); + $cfg = str_replace("\r\n", "\n", $cfg); + + $scalar = is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true); + $line = '$' . $key . ' = ' . $scalar . ';'; + + $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; + if (preg_match($pattern, $cfg)) { + $cfg = preg_replace($pattern, $line, $cfg, 1); + } else { + if (preg_match('/\?>\s*$/', $cfg)) { + $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); + } else { + if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; + $cfg .= $line . "\n"; + } + } + + $dir = dirname($file); + $temp = tempnam($dir, 'cfg_'); + if ($temp === false) throw new RuntimeException("Failed to create temp file in $dir"); + if (file_put_contents($temp, $cfg, LOCK_EX) === false) { + @unlink($temp); + throw new RuntimeException("Failed to write temp config"); + } + + $perms = @fileperms($file); + if ($perms !== false) { @chmod($temp, $perms & 0777); } + if (!@rename($temp, $file)) { + @unlink($temp); + throw new RuntimeException("Failed to atomically replace config.php"); + } + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } + } + } + // ---------- /inline helpers ---------- + // --- Basic env guards for long operations --- @set_time_limit(0); if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); } @@ -138,19 +257,16 @@ if (isset($_POST['restore'])) { } $file = $_FILES['backup_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."); @@ -164,7 +280,7 @@ if (isset($_POST['restore'])) { } @chmod($tempZip, 0600); - // --- 3) Extract safely to unique temp dir --- + // --- 3) Extract the OUTER backup zip to a unique temp dir --- $tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6)); if (!mkdir($tempDir, 0700, true)) { @unlink($tempZip); @@ -189,9 +305,9 @@ if (isset($_POST['restore'])) { $zip->close(); @unlink($tempZip); - // Paths inside extracted archive + // --- Expected inner files --- $sqlPath = $tempDir . "/db.sql"; - $uploadsZip = $tempDir . "/uploads.zip"; + $uploadsZip = $tempDir . "/uploads.zip"; // <- inner uploads zip $versionTxt = $tempDir . "/version.txt"; if (!is_file($sqlPath) || !is_readable($sqlPath)) { @@ -203,14 +319,12 @@ if (isset($_POST['restore'])) { die("Missing uploads.zip in the backup archive."); } - // --- 4) Optional: check version compatibility --- + // --- 4) Optional: check DB 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."); @@ -219,7 +333,6 @@ if (isset($_POST['restore'])) { } // --- 5) Restore SQL (drop + import) --- - // Drop all tables mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0"); $tables = mysqli_query($mysqli, "SHOW TABLES"); if ($tables) { @@ -237,11 +350,10 @@ if (isset($_POST['restore'])) { die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // --- 6) Restore uploads via staging + atomic swap --- + // --- 6) Restore UPLOADS (the inner uploads.zip) via staging + robust 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); @@ -249,13 +361,16 @@ if (isset($_POST['restore'])) { } $uploadDir = realpath($uploadDir); } - - if ($uploadDir === false || str_starts_with($uploadDir, $appRoot) === false) { + if ($uploadDir === false || strpos($uploadDir, $appRoot) !== 0) { deleteDir($tempDir); die("Uploads directory path invalid."); } + if (!is_writable(dirname($uploadDir))) { + deleteDir($tempDir); + die("Uploads restore failed: target parent dir is not writable by web server."); + } - $staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4)); + $staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4)); if (!mkdir($staging, 0700, true)) { deleteDir($tempDir); die("Failed to create staging directory."); @@ -268,9 +383,9 @@ if (isset($_POST['restore'])) { die("Failed to open uploads.zip in backup."); } - // IMPORTANT: staging dir should be empty here (as in your existing flow) + // Validate + buffer all entries; no writes if any issue (scan-report mode) $result = extractUploadsZipWithValidationReport($uz, $staging, [ - 'max_file_bytes' => 200 * 1024 * 1024, // adjust per-file size cap + 'max_file_bytes' => 200 * 1024 * 1024, 'blocked_exts' => [ 'php','php3','php4','php5','php7','php8','phtml','phar', 'cgi','pl','sh','bash','zsh','exe','dll','bat','cmd','com', @@ -280,38 +395,73 @@ if (isset($_POST['restore'])) { $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)) { + // If the inner zip has a single top-level folder (e.g., "uploads/"), promote it + $roots = listTopLevel($staging); + if (count($roots) === 1) { + $candidate = $staging . DIRECTORY_SEPARATOR . $roots[0]; + if (is_dir($candidate)) { + $stagingPromoted = $staging . "_promoted"; + if (!@rename($candidate, $stagingPromoted)) { + recursiveCopy($candidate, $stagingPromoted); // cross-FS fallback + deleteDir($candidate); + } + $old = $staging; + $staging = $stagingPromoted; + deleteDir($old); + } + } + + // Ensure staging has content + if (countFilesRecursive($staging) === 0) { deleteDir($staging); deleteDir($tempDir); - die("Failed to rotate old uploads."); + die("Uploads restore failed: extracted staging is empty. The inner uploads.zip may be malformed or all files were blocked."); } - if (!rename($staging, $uploadDir)) { - // try to revert - @rename($backupOld, $uploadDir); + + // Rotate current uploads out; robust moves on both steps + $backupOld = $appRoot . "/uploads_old_" . time(); + try { + if (is_dir($uploadDir)) { + if (!@rename($uploadDir, $backupOld)) { + recursiveCopy($uploadDir, $backupOld); // cross-FS fallback + deleteDir($uploadDir); + } + } + robustDirMove($staging, $uploadDir); + } catch (Throwable $e) { + // rollback if possible + if (is_dir($backupOld) && !is_dir($uploadDir)) { + @rename($backupOld, $uploadDir); + } + deleteDir($staging); deleteDir($tempDir); - die("Failed to promote restored uploads."); + die("Uploads restore failed during swap: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // Optional: clean old uploads now or keep briefly for rollback + + // Verify restored uploads; rollback if empty + $restoredCount = countFilesRecursive($uploadDir); + if ($restoredCount === 0) { + if (is_dir($backupOld)) { + @rename($uploadDir, $uploadDir . "_bad_" . time()); + @rename($backupOld, $uploadDir); + } + deleteDir($tempDir); + die("Uploads restore appears empty after swap. Rolled back to old uploads."); + } + // Optionally delete the old uploads after a grace period: // deleteDir($backupOld); // --- 7) Log version info (optional) --- @@ -325,19 +475,26 @@ if (isset($_POST['restore'])) { // --- 8) Cleanup temp dir --- deleteDir($tempDir); - // --- 9) Finalize setup flag --- - $myfile = fopen("../config.php", "a"); - $txt = "\$config_enable_setup = 0;\n\n"; - - fwrite($myfile, $txt); - fclose($myfile); + // --- 9) Finalize setup flag atomically (and clear OPcache) --- + try { + setConfigFlagAtomic(__DIR__ . "/../config.php", "config_enable_setup", 0); + } catch (Throwable $e) { + // Fallback append (best-effort) and allow login + @file_put_contents(__DIR__ . "/../config.php", "\n\$config_enable_setup = 0;\n", FILE_APPEND); + $_SESSION['alert_message'] = + "Backup restored, but couldn’t finalize setup flag automatically: " . + htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); + header("Location: ../login.php"); + exit; + } // --- 10) Done --- - $_SESSION['alert_message'] = "Full backup restored successfully."; + $_SESSION['alert_message'] = "Full backup restored successfully. Restored {$restoredCount} upload file(s)."; header("Location: ../login.php"); exit; } + if (isset($_POST['add_user'])) { $user_count = mysqli_num_rows(mysqli_query($mysqli,"SELECT COUNT(*) FROM users")); if ($user_count < 0) { From d122d90a47a3c12e6a733ed649943fdbb625c488 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 18:11:16 -0400 Subject: [PATCH 005/112] Remove CSRF check --- setup/index.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/setup/index.php b/setup/index.php index 71868d0e..8a8aaa47 100644 --- a/setup/index.php +++ b/setup/index.php @@ -126,15 +126,8 @@ if (isset($_POST['add_database'])) { } - Date: Thu, 9 Oct 2025 18:27:35 -0400 Subject: [PATCH 006/112] Another attempt at restore --- setup/index.php | 129 ++++++++++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/setup/index.php b/setup/index.php index 8a8aaa47..83bfab17 100644 --- a/setup/index.php +++ b/setup/index.php @@ -155,14 +155,6 @@ if (isset($_POST['restore'])) { } } - if (!function_exists('robustDirMove')) { - function robustDirMove(string $src, string $dst): void { - if (@rename($src, $dst)) return; // fast path (same filesystem) - recursiveCopy($src, $dst); // cross-FS fallback - deleteDir($src); - } - } - if (!function_exists('listTopLevel')) { function listTopLevel(string $dir): array { if (!is_dir($dir)) return []; @@ -193,6 +185,46 @@ if (isset($_POST['restore'])) { } } + if (!function_exists('mergeCopyCount')) { + /** + * Merge-copy all files from $src into $dst, creating subdirs as needed. + * Overwrites same-named files. Returns number of files written/overwritten. + */ + function mergeCopyCount(string $src, string $dst): int { + if (!is_dir($src)) return 0; + if (!is_dir($dst) && !mkdir($dst, 0750, true)) { + throw new RuntimeException("Failed to create destination: $dst"); + } + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + $written = 0; + foreach ($it as $item) { + $rel = substr($item->getPathname(), strlen($src) + 1); // relative path + $target = $dst . DIRECTORY_SEPARATOR . $rel; + + if ($item->isDir()) { + if (!is_dir($target) && !mkdir($target, 0750, true)) { + throw new RuntimeException("Failed to create directory: $target"); + } + } else { + $parent = dirname($target); + if (!is_dir($parent) && !mkdir($parent, 0750, true)) { + throw new RuntimeException("Failed to create directory: $parent"); + } + if (!copy($item->getPathname(), $target)) { + throw new RuntimeException("Failed to copy file: " . $item->getPathname()); + } + @chmod($target, 0640); + $written++; + } + } + return $written; + } + } + if (!function_exists('setConfigFlagAtomic')) { function setConfigFlagAtomic(string $file, string $key, $value): void { clearstatcache(true, $file); @@ -238,9 +270,9 @@ if (isset($_POST['restore'])) { } } } - // ---------- /inline helpers ---------- + // ---------- /helpers ---------- - // --- Basic env guards for long operations --- + // --- Long ops guard --- @set_time_limit(0); if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); } @@ -273,7 +305,7 @@ if (isset($_POST['restore'])) { } @chmod($tempZip, 0600); - // --- 3) Extract the OUTER backup zip to a unique temp dir --- + // --- 3) Extract OUTER backup zip --- $tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6)); if (!mkdir($tempDir, 0700, true)) { @unlink($tempZip); @@ -298,9 +330,9 @@ if (isset($_POST['restore'])) { $zip->close(); @unlink($tempZip); - // --- Expected inner files --- + // Expected paths inside extracted archive $sqlPath = $tempDir . "/db.sql"; - $uploadsZip = $tempDir . "/uploads.zip"; // <- inner uploads zip + $uploadsZip = $tempDir . "/uploads.zip"; // inner uploads zip $versionTxt = $tempDir . "/version.txt"; if (!is_file($sqlPath) || !is_readable($sqlPath)) { @@ -312,7 +344,7 @@ if (isset($_POST['restore'])) { die("Missing uploads.zip in the backup archive."); } - // --- 4) Optional: check DB version compatibility --- + // --- 4) Optional: version compatibility check --- if (defined('LATEST_DATABASE_VERSION') && is_file($versionTxt)) { $txt = @file_get_contents($versionTxt) ?: ''; if (preg_match('/^Database Version:\s*(.+)$/mi', $txt, $m)) { @@ -343,7 +375,7 @@ if (isset($_POST['restore'])) { die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // --- 6) Restore UPLOADS (the inner uploads.zip) via staging + robust swap --- + // --- 6) Restore UPLOADS (inner uploads.zip) via VALIDATED MERGE --- $appRoot = realpath(__DIR__ . "/.."); $uploadDir = realpath($appRoot . "/uploads"); if ($uploadDir === false) { @@ -358,17 +390,20 @@ if (isset($_POST['restore'])) { deleteDir($tempDir); die("Uploads directory path invalid."); } - if (!is_writable(dirname($uploadDir))) { + if (!is_writable($uploadDir)) { + // We need to write *inside* uploads for merge; not just the parent. deleteDir($tempDir); - die("Uploads restore failed: target parent dir is not writable by web server."); + die("Uploads restore failed: uploads directory is not writable by web server."); } + // Prepare staging area to extract inner uploads.zip $staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4)); if (!mkdir($staging, 0700, true)) { deleteDir($tempDir); die("Failed to create staging directory."); } + // Open inner uploads.zip $uz = new ZipArchive; if ($uz->open($uploadsZip) !== TRUE) { deleteDir($staging); @@ -376,7 +411,7 @@ if (isset($_POST['restore'])) { die("Failed to open uploads.zip in backup."); } - // Validate + buffer all entries; no writes if any issue (scan-report mode) + // Validate contents (scan report mode). On any issue, nothing is written. $result = extractUploadsZipWithValidationReport($uz, $staging, [ 'max_file_bytes' => 200 * 1024 * 1024, 'blocked_exts' => [ @@ -401,14 +436,15 @@ if (isset($_POST['restore'])) { exit; } - // If the inner zip has a single top-level folder (e.g., "uploads/"), promote it + // If inner zip has a single top-level folder (e.g., "uploads/"), promote it $roots = listTopLevel($staging); if (count($roots) === 1) { $candidate = $staging . DIRECTORY_SEPARATOR . $roots[0]; if (is_dir($candidate)) { $stagingPromoted = $staging . "_promoted"; if (!@rename($candidate, $stagingPromoted)) { - recursiveCopy($candidate, $stagingPromoted); // cross-FS fallback + // cross-FS fallback + recursiveCopy($candidate, $stagingPromoted); deleteDir($candidate); } $old = $staging; @@ -417,44 +453,44 @@ if (isset($_POST['restore'])) { } } - // Ensure staging has content + // Sanity: staging must have content if (countFilesRecursive($staging) === 0) { deleteDir($staging); deleteDir($tempDir); die("Uploads restore failed: extracted staging is empty. The inner uploads.zip may be malformed or all files were blocked."); } - // Rotate current uploads out; robust moves on both steps + // Snapshot current uploads for rollback, then MERGE staging -> uploads $backupOld = $appRoot . "/uploads_old_" . time(); try { - if (is_dir($uploadDir)) { - if (!@rename($uploadDir, $backupOld)) { - recursiveCopy($uploadDir, $backupOld); // cross-FS fallback - deleteDir($uploadDir); - } + // Full snapshot for safety + recursiveCopy($uploadDir, $backupOld); + + // Merge-copy into existing uploads (create dirs, overwrite same-named files) + $written = mergeCopyCount($staging, $uploadDir); + + if ($written <= 0) { + // No files written — something's off. Rollback. + throw new RuntimeException("No files were merged into uploads (written=$written)."); } - robustDirMove($staging, $uploadDir); + } catch (Throwable $e) { - // rollback if possible - if (is_dir($backupOld) && !is_dir($uploadDir)) { - @rename($backupOld, $uploadDir); + // Rollback to pre-merge state + try { + if (is_dir($backupOld)) { + // Restore snapshot over current uploads + deleteDir($uploadDir); + recursiveCopy($backupOld, $uploadDir); + } + } catch (\Throwable $rollbackErr) { + // Best effort rollback } deleteDir($staging); deleteDir($tempDir); - die("Uploads restore failed during swap: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + die("Uploads restore failed during merge: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // Verify restored uploads; rollback if empty - $restoredCount = countFilesRecursive($uploadDir); - if ($restoredCount === 0) { - if (is_dir($backupOld)) { - @rename($uploadDir, $uploadDir . "_bad_" . time()); - @rename($backupOld, $uploadDir); - } - deleteDir($tempDir); - die("Uploads restore appears empty after swap. Rolled back to old uploads."); - } - // Optionally delete the old uploads after a grace period: + // Optional: keep $backupOld for a while; or delete it once you confirm // deleteDir($backupOld); // --- 7) Log version info (optional) --- @@ -465,14 +501,14 @@ if (isset($_POST['restore'])) { } } - // --- 8) Cleanup temp dir --- + // --- 8) Cleanup temp dir + staging --- + deleteDir($staging); deleteDir($tempDir); // --- 9) Finalize setup flag atomically (and clear OPcache) --- try { setConfigFlagAtomic(__DIR__ . "/../config.php", "config_enable_setup", 0); } catch (Throwable $e) { - // Fallback append (best-effort) and allow login @file_put_contents(__DIR__ . "/../config.php", "\n\$config_enable_setup = 0;\n", FILE_APPEND); $_SESSION['alert_message'] = "Backup restored, but couldn’t finalize setup flag automatically: " . @@ -482,12 +518,11 @@ if (isset($_POST['restore'])) { } // --- 10) Done --- - $_SESSION['alert_message'] = "Full backup restored successfully. Restored {$restoredCount} upload file(s)."; + $_SESSION['alert_message'] = "Full backup restored successfully. Merged {$written} upload file(s)."; header("Location: ../login.php"); exit; } - if (isset($_POST['add_user'])) { $user_count = mysqli_num_rows(mysqli_query($mysqli,"SELECT COUNT(*) FROM users")); if ($user_count < 0) { From 986f6884689644ba0baf8defe08fc5256c63d4cc Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 18:49:54 -0400 Subject: [PATCH 007/112] another Attempt at restore --- setup/index.php | 364 +++++++++--------- setup/setup_functions.php | 758 +++++++++++++++++++++++--------------- 2 files changed, 642 insertions(+), 480 deletions(-) diff --git a/setup/index.php b/setup/index.php index 83bfab17..c6883943 100644 --- a/setup/index.php +++ b/setup/index.php @@ -128,103 +128,7 @@ if (isset($_POST['add_database'])) { if (isset($_POST['restore'])) { - // ---------- Inline helpers (guarded) ---------- - if (!function_exists('recursiveCopy')) { - function recursiveCopy(string $src, string $dst): void { - if (!is_dir($src)) throw new RuntimeException("Source directory missing: $src"); - if (!is_dir($dst) && !mkdir($dst, 0750, true)) { - throw new RuntimeException("Failed to create destination: $dst"); - } - $dir = opendir($src); - if (!$dir) throw new RuntimeException("Failed to open source: $src"); - while (($file = readdir($dir)) !== false) { - if ($file === '.' || $file === '..') continue; - $from = $src . DIRECTORY_SEPARATOR . $file; - $to = $dst . DIRECTORY_SEPARATOR . $file; - if (is_dir($from)) { - recursiveCopy($from, $to); - } else { - if (!copy($from, $to)) { - closedir($dir); - throw new RuntimeException("Copy failed: $from → $to"); - } - @chmod($to, 0640); - } - } - closedir($dir); - } - } - - if (!function_exists('listTopLevel')) { - function listTopLevel(string $dir): array { - if (!is_dir($dir)) return []; - $items = []; - $dh = opendir($dir); - if (!$dh) return []; - while (($e = readdir($dh)) !== false) { - if ($e === '.' || $e === '..') continue; - $items[] = $e; - } - closedir($dh); - sort($items); - return $items; - } - } - - if (!function_exists('countFilesRecursive')) { - function countFilesRecursive(string $dir): int { - if (!is_dir($dir)) return 0; - $it = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) - ); - $count = 0; - foreach ($it as $f) { - if ($f->isFile()) $count++; - } - return $count; - } - } - - if (!function_exists('mergeCopyCount')) { - /** - * Merge-copy all files from $src into $dst, creating subdirs as needed. - * Overwrites same-named files. Returns number of files written/overwritten. - */ - function mergeCopyCount(string $src, string $dst): int { - if (!is_dir($src)) return 0; - if (!is_dir($dst) && !mkdir($dst, 0750, true)) { - throw new RuntimeException("Failed to create destination: $dst"); - } - $it = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - - $written = 0; - foreach ($it as $item) { - $rel = substr($item->getPathname(), strlen($src) + 1); // relative path - $target = $dst . DIRECTORY_SEPARATOR . $rel; - - if ($item->isDir()) { - if (!is_dir($target) && !mkdir($target, 0750, true)) { - throw new RuntimeException("Failed to create directory: $target"); - } - } else { - $parent = dirname($target); - if (!is_dir($parent) && !mkdir($parent, 0750, true)) { - throw new RuntimeException("Failed to create directory: $parent"); - } - if (!copy($item->getPathname(), $target)) { - throw new RuntimeException("Failed to copy file: " . $item->getPathname()); - } - @chmod($target, 0640); - $written++; - } - } - return $written; - } - } - + // ---------- Tiny atomic config helper (guarded) ---------- if (!function_exists('setConfigFlagAtomic')) { function setConfigFlagAtomic(string $file, string $key, $value): void { clearstatcache(true, $file); @@ -252,17 +156,16 @@ if (isset($_POST['restore'])) { } $dir = dirname($file); - $temp = tempnam($dir, 'cfg_'); - if ($temp === false) throw new RuntimeException("Failed to create temp file in $dir"); - if (file_put_contents($temp, $cfg, LOCK_EX) === false) { - @unlink($temp); + $tmp = tempnam($dir, 'cfg_'); + if ($tmp === false) throw new RuntimeException("Failed to create temp file in $dir"); + if (file_put_contents($tmp, $cfg, LOCK_EX) === false) { + @unlink($tmp); throw new RuntimeException("Failed to write temp config"); } - $perms = @fileperms($file); - if ($perms !== false) { @chmod($temp, $perms & 0777); } - if (!@rename($temp, $file)) { - @unlink($temp); + if ($perms !== false) @chmod($tmp, $perms & 0777); + if (!@rename($tmp, $file)) { + @unlink($tmp); throw new RuntimeException("Failed to atomically replace config.php"); } if (function_exists('opcache_invalidate')) { @@ -270,13 +173,37 @@ if (isset($_POST['restore'])) { } } } - // ---------- /helpers ---------- + if (!function_exists('setConfigFlag')) { + function setConfigFlag(string $file, string $key, $value): void { + $cfg = @file_get_contents($file); + if ($cfg === false) throw new RuntimeException("Cannot read $file"); + $cfg = str_replace("\r\n", "\n", $cfg); + $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; + $line = '$' . $key . ' = ' . (is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true)) . ';'; + if (preg_match($pattern, $cfg)) { + $cfg = preg_replace($pattern, $line, $cfg, 1); + } else { + if (preg_match('/\?>\s*$/', $cfg)) { + $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); + } else { + if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; + $cfg .= $line . "\n"; + } + } + if (file_put_contents($file, $cfg, LOCK_EX) === false) { + throw new RuntimeException("Failed to update $file"); + } + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } + } + } - // --- Long ops guard --- + // ---------- Environment guards ---------- @set_time_limit(0); if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); } - // --- 1) Validate uploaded file --- + // ---------- 1) Validate uploaded file ---------- if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) { die("No backup file uploaded or upload failed."); } @@ -297,7 +224,7 @@ if (isset($_POST['restore'])) { die("Only .zip files are allowed."); } - // --- 2) Move to secure temp location --- + // ---------- 2) Save outer zip to temp ---------- $timestamp = date('YmdHis'); $tempZip = tempnam(sys_get_temp_dir(), "restore_{$timestamp}_"); if (!move_uploaded_file($file["tmp_name"], $tempZip)) { @@ -305,7 +232,7 @@ if (isset($_POST['restore'])) { } @chmod($tempZip, 0600); - // --- 3) Extract OUTER backup zip --- + // ---------- 3) Extract OUTER backup zip ---------- $tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6)); if (!mkdir($tempDir, 0700, true)) { @unlink($tempZip); @@ -320,7 +247,7 @@ if (isset($_POST['restore'])) { } try { - safeExtractZip($zip, $tempDir); + safeExtractZip($zip, $tempDir); // helper (safe extraction) } catch (Throwable $e) { $zip->close(); @unlink($tempZip); @@ -330,9 +257,8 @@ if (isset($_POST['restore'])) { $zip->close(); @unlink($tempZip); - // Expected paths inside extracted archive $sqlPath = $tempDir . "/db.sql"; - $uploadsZip = $tempDir . "/uploads.zip"; // inner uploads zip + $uploadsZip = $tempDir . "/uploads.zip"; // inner uploads zip $versionTxt = $tempDir . "/version.txt"; if (!is_file($sqlPath) || !is_readable($sqlPath)) { @@ -344,7 +270,7 @@ if (isset($_POST['restore'])) { die("Missing uploads.zip in the backup archive."); } - // --- 4) Optional: version compatibility check --- + // ---------- 4) Optional: version compatibility ---------- if (defined('LATEST_DATABASE_VERSION') && is_file($versionTxt)) { $txt = @file_get_contents($versionTxt) ?: ''; if (preg_match('/^Database Version:\s*(.+)$/mi', $txt, $m)) { @@ -357,7 +283,7 @@ if (isset($_POST['restore'])) { } } - // --- 5) Restore SQL (drop + import) --- + // ---------- 5) Restore SQL ---------- mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0"); $tables = mysqli_query($mysqli, "SHOW TABLES"); if ($tables) { @@ -369,41 +295,28 @@ if (isset($_POST['restore'])) { mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1"); try { - importSqlFile($mysqli, $sqlPath); + importSqlFile($mysqli, $sqlPath); // helper } catch (Throwable $e) { deleteDir($tempDir); die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // --- 6) Restore UPLOADS (inner uploads.zip) via VALIDATED MERGE --- + // ---------- 6) Restore UPLOADS: DELETE existing, then REPLACE ---------- $appRoot = realpath(__DIR__ . "/.."); - $uploadDir = realpath($appRoot . "/uploads"); - if ($uploadDir === false) { - $uploadDir = $appRoot . "/uploads"; - if (!mkdir($uploadDir, 0750, true)) { - deleteDir($tempDir); - die("Failed to create uploads directory."); - } - $uploadDir = realpath($uploadDir); - } - if ($uploadDir === false || strpos($uploadDir, $appRoot) !== 0) { + if ($appRoot === false) { deleteDir($tempDir); - die("Uploads directory path invalid."); - } - if (!is_writable($uploadDir)) { - // We need to write *inside* uploads for merge; not just the parent. - deleteDir($tempDir); - die("Uploads restore failed: uploads directory is not writable by web server."); + die("Failed to resolve app root."); } - // Prepare staging area to extract inner uploads.zip + $uploadDir = $appRoot . "/uploads"; + + // Extract inner uploads.zip to staging (with validation & scan report) $staging = $appRoot . "/uploads_restoring_" . bin2hex(random_bytes(4)); if (!mkdir($staging, 0700, true)) { deleteDir($tempDir); die("Failed to create staging directory."); } - // Open inner uploads.zip $uz = new ZipArchive; if ($uz->open($uploadsZip) !== TRUE) { deleteDir($staging); @@ -411,8 +324,7 @@ if (isset($_POST['restore'])) { die("Failed to open uploads.zip in backup."); } - // Validate contents (scan report mode). On any issue, nothing is written. - $result = extractUploadsZipWithValidationReport($uz, $staging, [ + $scan = extractUploadsZipWithValidationReport($uz, $staging, [ 'max_file_bytes' => 200 * 1024 * 1024, 'blocked_exts' => [ 'php','php3','php4','php5','php7','php8','phtml','phar', @@ -422,9 +334,9 @@ if (isset($_POST['restore'])) { ]); $uz->close(); - if (!$result['ok']) { + if (!$scan['ok']) { $lines = ["Unsafe file(s) detected in uploads.zip:"]; - foreach ($result['issues'] as $issue) { + foreach ($scan['issues'] as $issue) { $p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8'); $r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8'); $lines[] = "• {$p} — {$r}"; @@ -436,64 +348,131 @@ if (isset($_POST['restore'])) { exit; } - // If inner zip has a single top-level folder (e.g., "uploads/"), promote it - $roots = listTopLevel($staging); + // If inner zip has a single "uploads/" folder, promote its contents + $roots = []; + if ($dh = opendir($staging)) { + while (($e = readdir($dh)) !== false) { + if ($e === '.' || $e === '..') continue; + $roots[] = $e; + } + closedir($dh); + } + sort($roots); if (count($roots) === 1) { $candidate = $staging . DIRECTORY_SEPARATOR . $roots[0]; if (is_dir($candidate)) { - $stagingPromoted = $staging . "_promoted"; - if (!@rename($candidate, $stagingPromoted)) { - // cross-FS fallback - recursiveCopy($candidate, $stagingPromoted); - deleteDir($candidate); + $promoted = $staging . "_promoted"; + if (!@rename($candidate, $promoted)) { + // fallback to copy + $rit = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($candidate, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + if (!mkdir($promoted, 0700, true)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create promoted staging directory."); + } + foreach ($rit as $it) { + $rel = substr($it->getPathname(), strlen($candidate) + 1); + $dst = $promoted . DIRECTORY_SEPARATOR . $rel; + if ($it->isDir()) { + if (!is_dir($dst) && !mkdir($dst, 0700, true)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create $dst"); + } + } else { + $pdir = dirname($dst); + if (!is_dir($pdir) && !mkdir($pdir, 0700, true)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create $pdir"); + } + if (!copy($it->getPathname(), $dst)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to copy $rel into promoted staging"); + } + @chmod($dst, 0640); + } + } } - $old = $staging; - $staging = $stagingPromoted; - deleteDir($old); + deleteDir($staging); + $staging = isset($promoted) ? $promoted : $staging; } } - // Sanity: staging must have content - if (countFilesRecursive($staging) === 0) { + // Sanity: staging must contain files + $hasFiles = false; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS) + ); + foreach ($it as $f) { if ($f->isFile()) { $hasFiles = true; break; } } + if (!$hasFiles) { deleteDir($staging); deleteDir($tempDir); - die("Uploads restore failed: extracted staging is empty. The inner uploads.zip may be malformed or all files were blocked."); + die("Uploads restore failed: staging is empty (inner uploads.zip may be malformed or fully blocked)."); } - // Snapshot current uploads for rollback, then MERGE staging -> uploads - $backupOld = $appRoot . "/uploads_old_" . time(); - try { - // Full snapshot for safety - recursiveCopy($uploadDir, $backupOld); - - // Merge-copy into existing uploads (create dirs, overwrite same-named files) - $written = mergeCopyCount($staging, $uploadDir); - - if ($written <= 0) { - // No files written — something's off. Rollback. - throw new RuntimeException("No files were merged into uploads (written=$written)."); + // --- DELETE existing /uploads first, then REPLACE with staging --- + if (is_dir($uploadDir)) { + deleteDir($uploadDir, $appRoot); // guarded delete (under app root) + } + if (!@rename($staging, $uploadDir)) { + // fallback: copy + if (!mkdir($uploadDir, 0750, true)) { + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create uploads directory for placement."); } - - } catch (Throwable $e) { - // Rollback to pre-merge state - try { - if (is_dir($backupOld)) { - // Restore snapshot over current uploads - deleteDir($uploadDir); - recursiveCopy($backupOld, $uploadDir); + $rit = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($rit as $it) { + $rel = substr($it->getPathname(), strlen($staging) + 1); + $dst = $uploadDir . DIRECTORY_SEPARATOR . $rel; + if ($it->isDir()) { + if (!is_dir($dst) && !mkdir($dst, 0750, true)) { + deleteDir($uploadDir); + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create uploads subdir: $dst"); + } + } else { + $pdir = dirname($dst); + if (!is_dir($pdir) && !mkdir($pdir, 0750, true)) { + deleteDir($uploadDir); + deleteDir($staging); + deleteDir($tempDir); + die("Failed to create uploads parent dir: $pdir"); + } + if (!copy($it->getPathname(), $dst)) { + deleteDir($uploadDir); + deleteDir($staging); + deleteDir($tempDir); + die("Failed to place file into uploads: $rel"); + } + @chmod($dst, 0640); } - } catch (\Throwable $rollbackErr) { - // Best effort rollback } deleteDir($staging); - deleteDir($tempDir); - die("Uploads restore failed during merge: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); } - // Optional: keep $backupOld for a while; or delete it once you confirm - // deleteDir($backupOld); + // Verify uploads has files + $okFiles = false; + $it2 = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($it2 as $f) { if ($f->isFile()) { $okFiles = true; break; } } + if (!$okFiles) { + deleteDir($uploadDir); + deleteDir($tempDir); + die("Uploads replace failed: resulting directory is empty."); + } - // --- 7) Log version info (optional) --- + // ---------- 7) Log version info (optional) ---------- if (is_file($versionTxt)) { $versionInfo = @file_get_contents($versionTxt); if ($versionInfo !== false) { @@ -501,24 +480,29 @@ if (isset($_POST['restore'])) { } } - // --- 8) Cleanup temp dir + staging --- - deleteDir($staging); + // ---------- 8) Cleanup temp ---------- deleteDir($tempDir); - // --- 9) Finalize setup flag atomically (and clear OPcache) --- + // ---------- 9) Finalize setup flag ---------- try { setConfigFlagAtomic(__DIR__ . "/../config.php", "config_enable_setup", 0); } catch (Throwable $e) { - @file_put_contents(__DIR__ . "/../config.php", "\n\$config_enable_setup = 0;\n", FILE_APPEND); - $_SESSION['alert_message'] = - "Backup restored, but couldn’t finalize setup flag automatically: " . - htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'); - header("Location: ../login.php"); - exit; + try { + setConfigFlag(__DIR__ . "/../config.php", "config_enable_setup", 0); + if (function_exists('opcache_invalidate')) { + @opcache_invalidate(__DIR__ . "/../config.php", true); + } + } catch (\Throwable $e2) { + $_SESSION['alert_message'] = + "Restore completed, but couldn’t finalize setup flag automatically: " . + htmlspecialchars($e2->getMessage(), ENT_QUOTES, 'UTF-8'); + header("Location: ../login.php"); + exit; + } } - // --- 10) Done --- - $_SESSION['alert_message'] = "Full backup restored successfully. Merged {$written} upload file(s)."; + // ---------- 10) Done ---------- + $_SESSION['alert_message'] = "Full backup restored successfully. Uploads directory was replaced."; header("Location: ../login.php"); exit; } diff --git a/setup/setup_functions.php b/setup/setup_functions.php index 02385b60..d575d577 100644 --- a/setup/setup_functions.php +++ b/setup/setup_functions.php @@ -1,118 +1,200 @@ isDir()) { @rmdir($item->getPathname()); } - else { @unlink($item->getPathname()); } - } - @rmdir($dir); -} +// ------------------------------ +// deleteDir +// ------------------------------ +if (!function_exists('deleteDir')) { + /** + * Delete a directory recursively. + * @param string $dir Path to delete + * @param string|null $mustBeUnder Optional root path guard; if set, $dir must be within this root + */ + function deleteDir(string $dir, ?string $mustBeUnder = null): void { + $dir = rtrim($dir, DIRECTORY_SEPARATOR); + if ($dir === '' || $dir === DIRECTORY_SEPARATOR) return; -/** Import a SQL file via mysqli, supporting custom DELIMITER and multi statements. */ -function importSqlFile(mysqli $mysqli, string $path): void { - if (!is_file($path) || !is_readable($path)) { - throw new RuntimeException("SQL file not found or unreadable: $path"); - } - $fh = fopen($path, 'r'); - if (!$fh) throw new RuntimeException("Failed to open SQL file"); - - $delimiter = ';'; - $statement = ''; - - while (($line = fgets($fh)) !== false) { - $trim = trim($line); - - // skip comments and empty lines - if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#')) { - continue; - } - - // handle DELIMITER changes - if (preg_match('/^DELIMITER\s+(.+)$/i', $trim, $m)) { - $delimiter = $m[1]; - continue; - } - - $statement .= $line; - - // end of statement? - if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) { - $sql = substr($statement, 0, -strlen($delimiter)); - if ($mysqli->multi_query($sql) === false) { - fclose($fh); - throw new RuntimeException("SQL error: ".$mysqli->error); + if ($mustBeUnder !== null) { + $root = realpath($mustBeUnder); + $real = realpath($dir); + if ($root === false || $real === false || strpos($real, $root) !== 0) { + // Refuse to delete if it's not under the allowed root + return; } - // flush any result sets - while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ } - $statement = ''; } + + if (!is_dir($dir)) return; + + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + $path = $item->getPathname(); + if ($item->isDir()) { @rmdir($path); } + else { @unlink($path); } + } + @rmdir($dir); } - fclose($fh); } -/** Extract a zip safely to $destDir. Blocks absolute paths, drive letters, and symlinks. */ -function safeExtractZip(ZipArchive $zip, string $destDir): void { - $rootReal = realpath($destDir); - if ($rootReal === false) { - if (!mkdir($destDir, 0700, true)) { +// ------------------------------ +// importSqlFile +// ------------------------------ +if (!function_exists('importSqlFile')) { + /** + * Import a SQL file via mysqli, supporting custom DELIMITER and multi statements. + * Executes a trailing, non-delimited statement at EOF (if present). + */ + function importSqlFile(mysqli $mysqli, string $path): void { + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException("SQL file not found or unreadable: $path"); + } + $fh = fopen($path, 'r'); + if (!$fh) throw new RuntimeException("Failed to open SQL file"); + + $delimiter = ';'; + $statement = ''; + + while (($line = fgets($fh)) !== false) { + $trim = trim($line); + + // skip comments and empty lines + if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#')) { + continue; + } + + // handle DELIMITER changes + if (preg_match('/^DELIMITER\s+(.+)$/i', $trim, $m)) { + $delimiter = $m[1]; + continue; + } + + $statement .= $line; + + // end of statement? + if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) { + $sql = substr($statement, 0, -strlen($delimiter)); + if ($mysqli->multi_query($sql) === false) { + fclose($fh); + throw new RuntimeException("SQL error: " . $mysqli->error); + } + while ($mysqli->more_results() && $mysqli->next_result()) { /* flush */ } + $statement = ''; + } + } + + // Trailing statement at EOF (no delimiter) + $trimStmt = trim($statement); + if ($trimStmt !== '') { + if ($mysqli->multi_query($trimStmt) === false) { + fclose($fh); + throw new RuntimeException("SQL error (EOF): " . $mysqli->error); + } + while ($mysqli->more_results() && $mysqli->next_result()) { /* flush */ } + } + + fclose($fh); + } +} + +// ------------------------------ +// safeExtractZip +// ------------------------------ +if (!function_exists('safeExtractZip')) { + /** + * Extract a zip safely to $destDir. + * - Blocks absolute paths and drive letters + * - Normalizes ".." before writing anything + * - Skips junk (e.g., __MACOSX/, .DS_Store, Thumbs.db) + * - Verifies boundary after writes and blocks symlinks + */ + function safeExtractZip(ZipArchive $zip, string $destDir): void { + if (!is_dir($destDir) && !mkdir($destDir, 0700, true)) { throw new RuntimeException("Failed to create temp dir"); } $rootReal = realpath($destDir); - } - - for ($i = 0; $i < $zip->numFiles; $i++) { - $name = $zip->getNameIndex($i); - - // Reject absolute or drive-lettered paths - if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { - throw new RuntimeException("Invalid absolute path in zip: $name"); + if ($rootReal === false) { + throw new RuntimeException("Failed to resolve destination"); } - // Normalize components and skip traversal attempts - $target = $rootReal . DIRECTORY_SEPARATOR . $name; - $targetDir = dirname($target); - if (!is_dir($targetDir) && !mkdir($targetDir, 0700, true)) { - throw new RuntimeException("Failed to create $targetDir"); - } + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); - // Directories end with '/' - $isDir = str_ends_with($name, '/'); - - // Read entry - $fp = $zip->getStream($name); - if ($fp === false) { - if ($isDir) continue; - throw new RuntimeException("Failed to read $name from zip"); - } - - if ($isDir) { - if (!is_dir($target) && !mkdir($target, 0700, true)) { - fclose($fp); - throw new RuntimeException("Failed to mkdir $target"); + // Skip junk/system entries + if ($name === '' || + str_starts_with($name, '__MACOSX/') || + preg_match('#/\.DS_Store$#i', $name) || + preg_match('#/Thumbs\.db$#i', $name) + ) { + continue; + } + + // Reject absolute paths / drive letters + if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { + throw new RuntimeException("Invalid absolute/drive path in zip: $name"); + } + + // Normalize path segments (handle .. and .) + $norm = []; + foreach (preg_split('#[\\\\/]#', $name) as $seg) { + if ($seg === '' || $seg === '.') continue; + if ($seg === '..') { array_pop($norm); continue; } + $norm[] = $seg; + } + $safeRel = implode(DIRECTORY_SEPARATOR, $norm); + if ($safeRel === '') continue; + + $isDir = str_ends_with($name, '/'); + $target = $rootReal . DIRECTORY_SEPARATOR . $safeRel; + $parent = dirname($target); + + // Create parent and verify boundary + if (!is_dir($parent) && !mkdir($parent, 0700, true)) { + throw new RuntimeException("Failed to create $parent"); + } + $parentReal = realpath($parent); + if ($parentReal === false || strpos($parentReal, $rootReal) !== 0) { + throw new RuntimeException("Path traversal detected (parent) for $name"); + } + + if ($isDir) { + if (!is_dir($target) && !mkdir($target, 0700, true)) { + throw new RuntimeException("Failed to mkdir $target"); + } + $dirReal = realpath($target); + if ($dirReal === false || strpos($dirReal, $rootReal) !== 0) { + @rmdir($target); + throw new RuntimeException("Boundary check failed (dir) for $name"); + } + continue; + } + + // Regular file + $fp = $zip->getStream($name); + if ($fp === false) { + throw new RuntimeException("Failed to read $name from zip"); } - fclose($fp); - } else { $out = fopen($target, 'wb'); if (!$out) { fclose($fp); throw new RuntimeException("Failed to create $target"); } stream_copy_to_stream($fp, $out); fclose($fp); fclose($out); + @chmod($target, 0640); - // Final boundary check $real = realpath($target); - if ($real === false || str_starts_with($real, $rootReal) === false) { + if ($real === false || strpos($real, $rootReal) !== 0) { @unlink($target); - throw new RuntimeException("Path traversal detected for $name"); + throw new RuntimeException("Boundary check failed (file) for $name"); } - - // Disallow symlinks (in case zip contained one) if (is_link($real)) { @unlink($real); throw new RuntimeException("Symlink detected in archive: $name"); @@ -121,227 +203,323 @@ function safeExtractZip(ZipArchive $zip, string $destDir): void { } } -/** Idempotently set/append a PHP config flag like $config_enable_setup = 0; */ -function setConfigFlag(string $file, string $key, $value): void { - $cfg = @file_get_contents($file); - if ($cfg === false) throw new RuntimeException("Cannot read $file"); - $pattern = '/^\s*\$'.preg_quote($key, '/').'\s*=\s*.*?;\s*$/m'; - $line = '$'.$key.' = '.(is_bool($value)? ($value?'true':'false') : var_export($value,true)).";\n"; - if (preg_match($pattern, $cfg)) { - $cfg = preg_replace($pattern, $line, $cfg); - } else { - $cfg .= "\n".$line; - } - if (file_put_contents($file, $cfg, LOCK_EX) === false) { - throw new RuntimeException("Failed to update $file"); +// ------------------------------ +// setConfigFlag (idempotent) +// ------------------------------ +if (!function_exists('setConfigFlag')) { + /** + * Idempotently set/append a PHP config flag like $config_enable_setup = 0; + */ + function setConfigFlag(string $file, string $key, $value): void { + $cfg = @file_get_contents($file); + if ($cfg === false) throw new RuntimeException("Cannot read $file"); + $cfg = str_replace("\r\n", "\n", $cfg); + + $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; + $line = '$' . $key . ' = ' . (is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true)) . ';'; + + if (preg_match($pattern, $cfg)) { + $cfg = preg_replace($pattern, $line, $cfg, 1); + } else { + if (preg_match('/\?>\s*$/', $cfg)) { + $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); + } else { + if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; + $cfg .= $line . "\n"; + } + } + + if (file_put_contents($file, $cfg, LOCK_EX) === false) { + throw new RuntimeException("Failed to update $file"); + } + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } } } -/** - * Return true if a filename has a disallowed extension or looks like a double-extension trick. - */ -function hasDangerousExtension(string $name, array $blockedExts): bool { - $lower = strtolower($name); - // Quick reject on hidden PHP or dotfiles that may alter server behavior - if (preg_match('/(^|\/)\.(htaccess|user\.ini|env)$/i', $lower)) return true; +// ------------------------------ +// setConfigFlagAtomic (preferred) +// ------------------------------ +if (!function_exists('setConfigFlagAtomic')) { + /** + * Atomic variant of setConfigFlag to avoid partial writes. + */ + function setConfigFlagAtomic(string $file, string $key, $value): void { + clearstatcache(true, $file); + if (!file_exists($file)) throw new RuntimeException("config.php not found: $file"); + if (!is_readable($file)) throw new RuntimeException("config.php not readable: $file"); + if (!is_writable($file)) throw new RuntimeException("config.php not writable: $file"); - // Pull last extension - $ext = strtolower(pathinfo($lower, PATHINFO_EXTENSION)); - if (in_array($ext, $blockedExts, true)) return true; + $cfg = file_get_contents($file); + if ($cfg === false) throw new RuntimeException("Failed to read config.php"); + $cfg = str_replace("\r\n", "\n", $cfg); - // Double extension (e.g., .jpg.php, .png.sh) - if (preg_match('/\.(?:[a-z0-9]{1,5})\.(php[0-9]?|phtml|phar|cgi|pl|sh|exe|dll|bat|cmd|com|ps1|vb|vbs|jar|jsp|asp|aspx)$/i', $lower)) { - return true; + $scalar = is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true); + $line = '$' . $key . ' = ' . $scalar . ';'; + + $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; + if (preg_match($pattern, $cfg)) { + $cfg = preg_replace($pattern, $line, $cfg, 1); + } else { + if (preg_match('/\?>\s*$/', $cfg)) { + $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); + } else { + if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; + $cfg .= $line . "\n"; + } + } + + $dir = dirname($file); + $tmp = tempnam($dir, 'cfg_'); + if ($tmp === false) throw new RuntimeException("Failed to create temp file in $dir"); + if (file_put_contents($tmp, $cfg, LOCK_EX) === false) { + @unlink($tmp); + throw new RuntimeException("Failed to write temp config"); + } + $perms = @fileperms($file); + if ($perms !== false) @chmod($tmp, $perms & 0777); + if (!@rename($tmp, $file)) { + @unlink($tmp); + throw new RuntimeException("Failed to atomically replace config.php"); + } + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } } - - return false; } -/** - * Heuristic content scan for executable code. Reads head/tail of file. - */ -function contentLooksExecutable(string $tmpPath): bool { - // Use finfo to detect executable/script mimetypes - $fi = new finfo(FILEINFO_MIME_TYPE); - $mime = $fi->file($tmpPath) ?: ''; +// ------------------------------ +// hasDangerousExtension +// ------------------------------ +if (!function_exists('hasDangerousExtension')) { + /** + * Return true if a filename has a disallowed extension or looks like a double-extension trick. + */ + function hasDangerousExtension(string $name, array $blockedExts): bool { + $lower = strtolower($name); - // Quick MIME-based blocks (don’t rely solely on this) - if (preg_match('#^(application/x-(php|elf|sharedlib|mach-o)|text/x-(php|script|shell))#i', $mime)) { - return true; + // Block config-like dotfiles that can affect server behavior + if (preg_match('/(^|\/)\.(htaccess|user\.ini|env|apache2?\.conf|nginx\.conf)$/i', $lower)) return true; + + $ext = strtolower(pathinfo($lower, PATHINFO_EXTENSION)); + if ($ext === '') return false; + + // Merge user blocklist with common server-parsed types + $blocked = array_flip($blockedExts) + array_flip([ + 'shtml','stm','shtm', // server-parsed HTML (SSI) + 'ctp', // CakePHP template + 'pht','phtm', // treated as PHP on misconfigs + ]); + if (isset($blocked[$ext])) return true; + + // Double extension like .jpg.php or .png.sh (cap first ext to 10 chars) + if (preg_match('/\.[a-z0-9]{1,10}\.(php[0-9]?|phtml|phar|cgi|pl|sh|exe|dll|bat|cmd|com|ps1|vb|vbs|jar|jsp|asp|aspx|s?html)$/i', $lower)) { + return true; + } + + return false; } - - // Read first/last 4KB for signature checks without loading whole file - $fp = @fopen($tmpPath, 'rb'); - if (!$fp) return false; - - $head = fread($fp, 4096) ?: ''; - // Seek last 4KB if file >4KB - $tail = ''; - $stat = fstat($fp); - if ($stat && $stat['size'] > 4096) { - fseek($fp, -4096, SEEK_END); - $tail = fread($fp, 4096) ?: ''; - } - fclose($fp); - - $blob = $head . $tail; - - // Block common code markers / execution hints - $markers = [ - '} - */ -function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir, array $options = []): array { - $maxFileBytes = $options['max_file_bytes'] ?? (200 * 1024 * 1024); // 200MB - $blockedExts = $options['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' - ]; +// ------------------------------ +// contentLooksExecutable +// ------------------------------ +if (!function_exists('contentLooksExecutable')) { + /** + * Heuristic content scan for executable code. Reads head/tail of file. + * Uses finfo when available; falls back to signature scan. + */ + function contentLooksExecutable(string $tmpPath): bool { + $mime = ''; + if (class_exists('finfo')) { + $fi = new finfo(FILEINFO_MIME_TYPE); + $mime = $fi->file($tmpPath) ?: ''; + if (preg_match('#^(application/x-(php|elf|sharedlib|mach-o)|text/x-(php|script|shell))#i', $mime)) { + return true; + } + } - $issues = []; - $staging = $destDir; // caller gives us a staging dir (empty) - $rootReal = realpath($staging); - if ($rootReal === false) { - if (!mkdir($staging, 0700, true)) { + $fp = @fopen($tmpPath, 'rb'); + if (!$fp) return false; + + $head = fread($fp, 4096) ?: ''; + $tail = ''; + $stat = fstat($fp); + if ($stat && ($stat['size'] ?? 0) > 4096) { + fseek($fp, -4096, SEEK_END); + $tail = fread($fp, 4096) ?: ''; + } + fclose($fp); + + $blob = $head . $tail; + + // Execution markers (limited to reduce false positives) + $markers = [ + '} + */ + function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir, array $options = []): array { + $maxFileBytes = $options['max_file_bytes'] ?? (200 * 1024 * 1024); // 200MB per-file + $maxTotalBytes = $options['max_total_bytes'] ?? (4 * 1024 * 1024 * 1024); // 4GB per-archive + $blockedExts = $options['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' + ]; + + $issues = []; + if (!is_dir($destDir) && !mkdir($destDir, 0700, true)) { return ['ok' => false, 'issues' => [['path' => '(staging)', 'reason' => 'Failed to create staging directory']]]; } - $rootReal = realpath($staging); - } - - // First pass: validate all entries and write candidates to temp files only - // We keep a map of tmp files to final target paths; if any issue is found, we clean them and return - $pending = []; // [ [ 'tmp' => '/tmp/..', 'target' => '/final/path', 'name' => 'zip/path' ], ... ] - - for ($i = 0; $i < $zip->numFiles; $i++) { - $name = $zip->getNameIndex($i); - - // Reject absolute/drive-lettered paths - if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { - $issues[] = ['path' => $name, 'reason' => 'Invalid absolute or drive path']; - continue; + $rootReal = realpath($destDir); + if ($rootReal === false) { + return ['ok' => false, 'issues' => [['path' => '(staging)', 'reason' => 'Failed to resolve staging directory']]]; } - $isDir = str_ends_with($name, '/'); - if ($isDir) { - // We’ll create directories in the commit phase if no issues were found - continue; - } + $pending = []; // list of ['tmp','target','name'] + $totalBytes = 0; - $stream = $zip->getStream($name); - if ($stream === false) { - $issues[] = ['path' => $name, 'reason' => 'Unable to read entry from ZIP']; - continue; - } + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); - // 1) Extension and double-extension checks - if (hasDangerousExtension($name, $blockedExts)) { - fclose($stream); - $issues[] = ['path' => $name, 'reason' => 'Dangerous or disallowed file extension']; - continue; - } - - // 2) Stream into a temp file (size-capped) for MIME/signature checks - $tmp = tempnam(sys_get_temp_dir(), 'uplscan_'); - if ($tmp === false) { fclose($stream); $issues[] = ['path' => $name, 'reason' => 'Failed to create temp file']; continue; } - - $out = fopen($tmp, 'wb'); - if (!$out) { fclose($stream); @unlink($tmp); $issues[] = ['path' => $name, 'reason' => 'Failed to write temp file']; continue; } - - $bytes = 0; - $err = null; - while (!feof($stream)) { - $chunk = fread($stream, 1 << 15); - if ($chunk === false) { $err = 'Read error while extracting'; break; } - $bytes += strlen($chunk); - if ($bytes > $maxFileBytes) { $err = 'File exceeds per-file size limit'; break; } - if (fwrite($out, $chunk) === false) { $err = 'Write error while buffering'; break; } - } - fclose($stream); - fclose($out); - - if ($err !== null) { - @unlink($tmp); - $issues[] = ['path' => $name, 'reason' => $err]; - continue; - } - - // 3) MIME + signature checks - if (contentLooksExecutable($tmp)) { - @unlink($tmp); - $issues[] = ['path' => $name, 'reason' => 'Executable/script content detected']; - continue; - } - - // Record as candidate for commit - $target = $rootReal . DIRECTORY_SEPARATOR . $name; - $pending[] = ['tmp' => $tmp, 'target' => $target, 'name' => $name]; - } - - // If any issues, cleanup temps and return report - if (!empty($issues)) { - foreach ($pending as $p) { @unlink($p['tmp']); } - return ['ok' => false, 'issues' => $issues]; - } - - // Commit phase: create directories and move files into place - foreach ($pending as $p) { - $finalDir = dirname($p['target']); - if (!is_dir($finalDir) && !mkdir($finalDir, 0700, true)) { - // Rollback partially moved files - foreach ($pending as $r) { @unlink($r['tmp']); } - return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to create destination directory']]]; - } - - // Boundary check again - $realFinalDir = realpath($finalDir); - if ($realFinalDir === false || strpos($realFinalDir, $rootReal) !== 0) { - foreach ($pending as $r) { @unlink($r['tmp']); } - return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Path traversal detected at commit phase']]]; - } - - if (!rename($p['tmp'], $p['target'])) { - if (!copy($p['tmp'], $p['target'])) { - @unlink($p['tmp']); - // Cleanup remaining temps - foreach ($pending as $r) { @unlink($r['tmp']); } - return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to place file in destination']]]; + // Skip junk/system entries + if ($name === '' || + str_starts_with($name, '__MACOSX/') || + preg_match('#/\.DS_Store$#i', $name) || + preg_match('#/Thumbs\.db$#i', $name) + ) { + continue; } - @unlink($p['tmp']); + + // Absolute / drive-lettered paths + if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { + $issues[] = ['path' => $name, 'reason' => 'Invalid absolute or drive path']; + continue; + } + + // Directories: defer creation to commit phase + if (str_ends_with($name, '/')) continue; + + $stream = $zip->getStream($name); + if ($stream === false) { + $issues[] = ['path' => $name, 'reason' => 'Unable to read entry from ZIP']; + continue; + } + + // Extension/double-extension checks + if (hasDangerousExtension($name, $blockedExts)) { + fclose($stream); + $issues[] = ['path' => $name, 'reason' => 'Dangerous or disallowed file extension']; + continue; + } + + // Buffer to temp with per-file & total size caps + $tmp = tempnam(sys_get_temp_dir(), 'uplscan_'); + if ($tmp === false) { fclose($stream); $issues[] = ['path' => $name, 'reason' => 'Failed to create temp file']; continue; } + + $out = fopen($tmp, 'wb'); + if (!$out) { fclose($stream); @unlink($tmp); $issues[] = ['path' => $name, 'reason' => 'Failed to write temp file']; continue; } + + $bytes = 0; $err = null; + while (!feof($stream)) { + $chunk = fread($stream, 1 << 15); + if ($chunk === false) { $err = 'Read error while extracting'; break; } + $len = strlen($chunk); + $bytes += $len; + $totalBytes += $len; + + if ($bytes > $maxFileBytes) { $err = 'File exceeds per-file size limit'; break; } + if ($totalBytes > $maxTotalBytes) { $err = 'Archive exceeds total size limit'; break; } + if (fwrite($out, $chunk) === false) { $err = 'Write error while buffering'; break; } + } + fclose($stream); + fclose($out); + + if ($err !== null) { + @unlink($tmp); + $issues[] = ['path' => $name, 'reason' => $err]; + continue; + } + + // MIME/signature check + if (contentLooksExecutable($tmp)) { + @unlink($tmp); + $issues[] = ['path' => $name, 'reason' => 'Executable/script content detected']; + continue; + } + + // Record as candidate + $target = $rootReal . DIRECTORY_SEPARATOR . $name; + $pending[] = ['tmp' => $tmp, 'target' => $target, 'name' => $name]; } - // Permissions & final checks - @chmod($p['target'], 0640); - $real = realpath($p['target']); - if ($real === false || strpos($real, $rootReal) !== 0) { - @unlink($p['target']); - foreach ($pending as $r) { @unlink($r['tmp']); } - return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Boundary check failed after write']]]; + // Any issues? clean up and report + if (!empty($issues)) { + foreach ($pending as $p) { @unlink($p['tmp']); } + return ['ok' => false, 'issues' => $issues]; } - if (is_link($real)) { - @unlink($real); - foreach ($pending as $r) { @unlink($r['tmp']); } - return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Symlink detected in destination']]]; + + // Commit: create dirs and move files + foreach ($pending as $p) { + $finalDir = dirname($p['target']); + if (!is_dir($finalDir) && !mkdir($finalDir, 0700, true)) { + foreach ($pending as $r) { @unlink($r['tmp']); } + return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to create destination directory']]]; + } + + $realFinalDir = realpath($finalDir); + if ($realFinalDir === false || strpos($realFinalDir, $rootReal) !== 0) { + foreach ($pending as $r) { @unlink($r['tmp']); } + return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Path traversal detected at commit phase']]]; + } + + if (!rename($p['tmp'], $p['target'])) { + if (!copy($p['tmp'], $p['target'])) { + @unlink($p['tmp']); + foreach ($pending as $r) { @unlink($r['tmp']); } + return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Failed to place file in destination']]]; + } + @unlink($p['tmp']); + } + + @chmod($p['target'], 0640); + $real = realpath($p['target']); + if ($real === false || strpos($real, $rootReal) !== 0) { + @unlink($p['target']); + foreach ($pending as $r) { @unlink($r['tmp']); } + return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Boundary check failed after write']]]; + } + if (is_link($real)) { + @unlink($real); + foreach ($pending as $r) { @unlink($r['tmp']); } + return ['ok' => false, 'issues' => [['path' => $p['name'], 'reason' => 'Symlink detected in destination']]]; + } } + + return ['ok' => true]; } - - return ['ok' => true]; -} \ No newline at end of file +} From c77e1be1c3b97cbfc3d5791cbd518e88412c2661 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 19:00:02 -0400 Subject: [PATCH 008/112] Try to fix uploads --- setup/index.php | 200 ++++++++++++++++++++++-------------------------- 1 file changed, 92 insertions(+), 108 deletions(-) diff --git a/setup/index.php b/setup/index.php index c6883943..b20799c6 100644 --- a/setup/index.php +++ b/setup/index.php @@ -302,136 +302,111 @@ if (isset($_POST['restore'])) { } // ---------- 6) Restore UPLOADS: DELETE existing, then REPLACE ---------- - $appRoot = realpath(__DIR__ . "/.."); - if ($appRoot === false) { - deleteDir($tempDir); - die("Failed to resolve app root."); +$appRoot = realpath(__DIR__ . "/.."); +if ($appRoot === false) { + deleteDir($tempDir); + die("Failed to resolve app root."); +} + +$uploadDir = $appRoot . "/uploads"; + +// 6.a Extract inner uploads.zip to staging (with validation & scan report) +$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."); +} + +$scan = extractUploadsZipWithValidationReport($uz, $staging, [ + 'max_file_bytes' => 200 * 1024 * 1024, + '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 (!$scan['ok']) { + $lines = ["Unsafe file(s) detected in uploads.zip:"]; + foreach ($scan['issues'] as $issue) { + $p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8'); + $r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8'); + $lines[] = "• {$p} — {$r}"; } + deleteDir($staging); + deleteDir($tempDir); + $_SESSION['alert_message'] = nl2br(implode("\n", $lines)); + header("Location: ?restore"); + exit; +} - $uploadDir = $appRoot . "/uploads"; - - // Extract inner uploads.zip to staging (with validation & scan report) - $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."); - } - - $scan = extractUploadsZipWithValidationReport($uz, $staging, [ - 'max_file_bytes' => 200 * 1024 * 1024, - '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 (!$scan['ok']) { - $lines = ["Unsafe file(s) detected in uploads.zip:"]; - foreach ($scan['issues'] as $issue) { - $p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8'); - $r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8'); - $lines[] = "• {$p} — {$r}"; - } - deleteDir($staging); - deleteDir($tempDir); - $_SESSION['alert_message'] = nl2br(implode("\n", $lines)); - header("Location: ?restore"); - exit; - } - - // If inner zip has a single "uploads/" folder, promote its contents - $roots = []; +// 6.b Determine the actual content root inside staging + // If the inner archive’s top-level is exactly "uploads/", use that; otherwise use staging itself. + $contentRoot = $staging; + $topEntries = []; if ($dh = opendir($staging)) { while (($e = readdir($dh)) !== false) { if ($e === '.' || $e === '..') continue; - $roots[] = $e; + $topEntries[] = $e; } closedir($dh); } - sort($roots); - if (count($roots) === 1) { - $candidate = $staging . DIRECTORY_SEPARATOR . $roots[0]; - if (is_dir($candidate)) { - $promoted = $staging . "_promoted"; - if (!@rename($candidate, $promoted)) { - // fallback to copy - $rit = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($candidate, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - if (!mkdir($promoted, 0700, true)) { - deleteDir($staging); - deleteDir($tempDir); - die("Failed to create promoted staging directory."); - } - foreach ($rit as $it) { - $rel = substr($it->getPathname(), strlen($candidate) + 1); - $dst = $promoted . DIRECTORY_SEPARATOR . $rel; - if ($it->isDir()) { - if (!is_dir($dst) && !mkdir($dst, 0700, true)) { - deleteDir($staging); - deleteDir($tempDir); - die("Failed to create $dst"); - } - } else { - $pdir = dirname($dst); - if (!is_dir($pdir) && !mkdir($pdir, 0700, true)) { - deleteDir($staging); - deleteDir($tempDir); - die("Failed to create $pdir"); - } - if (!copy($it->getPathname(), $dst)) { - deleteDir($staging); - deleteDir($tempDir); - die("Failed to copy $rel into promoted staging"); - } - @chmod($dst, 0640); - } - } - } - deleteDir($staging); - $staging = isset($promoted) ? $promoted : $staging; + sort($topEntries); + + if (count($topEntries) === 1) { + $candidate = $staging . DIRECTORY_SEPARATOR . $topEntries[0]; + if (is_dir($candidate) && strtolower($topEntries[0]) === 'uploads') { + // Use the "uploads" directory directly without moving/deleting staging yet + $contentRoot = $candidate; } } - // Sanity: staging must contain files + // 6.c Sanity check: content root must have files $hasFiles = false; - $it = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS) + $ritCheck = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($contentRoot, FilesystemIterator::SKIP_DOTS) ); - foreach ($it as $f) { if ($f->isFile()) { $hasFiles = true; break; } } + foreach ($ritCheck as $f) { + if ($f->isFile()) { $hasFiles = true; break; } + } if (!$hasFiles) { deleteDir($staging); deleteDir($tempDir); - die("Uploads restore failed: staging is empty (inner uploads.zip may be malformed or fully blocked)."); + die("Uploads restore failed: extracted content is empty (inner uploads.zip may be malformed or fully blocked)."); } - // --- DELETE existing /uploads first, then REPLACE with staging --- + // 6.d Remove current /uploads and replace with restored content if (is_dir($uploadDir)) { - deleteDir($uploadDir, $appRoot); // guarded delete (under app root) + deleteDir($uploadDir, $appRoot); // guarded delete under app root } - if (!@rename($staging, $uploadDir)) { - // fallback: copy + + // If contentRoot IS the staging root, try a simple rename + $renameSource = ($contentRoot === $staging) ? $staging : $contentRoot; + $renameOk = @rename($renameSource, $uploadDir); + + // If we used the child "uploads" dir as contentRoot, staging still exists with an empty top; clean it later + if (!$renameOk) { + // Fallback: copy tree if (!mkdir($uploadDir, 0750, true)) { deleteDir($staging); deleteDir($tempDir); die("Failed to create uploads directory for placement."); } + $rit = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS), + new RecursiveDirectoryIterator($contentRoot, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($rit as $it) { - $rel = substr($it->getPathname(), strlen($staging) + 1); + $rel = substr($it->getPathname(), strlen($contentRoot) + 1); $dst = $uploadDir . DIRECTORY_SEPARATOR . $rel; if ($it->isDir()) { if (!is_dir($dst) && !mkdir($dst, 0750, true)) { @@ -457,17 +432,26 @@ if (isset($_POST['restore'])) { @chmod($dst, 0640); } } + // If we copied only the inner "uploads" folder, we still need to clean the top-level staging deleteDir($staging); + } else { + // rename succeeded; if we renamed the child "uploads", the original staging still exists – clean it + if ($contentRoot !== $staging) { + deleteDir($staging); + } } - // Verify uploads has files - $okFiles = false; - $it2 = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS) + // 6.e Verify uploads now has files and report counts + $fileCount = 0; $dirCount = 0; + $ritVerify = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST ); - foreach ($it2 as $f) { if ($f->isFile()) { $okFiles = true; break; } } - if (!$okFiles) { - deleteDir($uploadDir); + foreach ($ritVerify as $it) { + if ($it->isDir()) $dirCount++; + else $fileCount++; + } + if ($fileCount === 0) { deleteDir($tempDir); die("Uploads replace failed: resulting directory is empty."); } From b336ec418895ee768f86aa3cd56027c17f5f1bde Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 19:14:31 -0400 Subject: [PATCH 009/112] Revert setup restore to a saner version --- setup/index.php | 473 ++++++++++++++++++------------------------------ 1 file changed, 173 insertions(+), 300 deletions(-) diff --git a/setup/index.php b/setup/index.php index b20799c6..52d29482 100644 --- a/setup/index.php +++ b/setup/index.php @@ -6,7 +6,6 @@ if (file_exists("../config.php")) { } include "../functions.php"; // Global Functions -include "setup_functions.php"; // Setup Only Functions include "../includes/database_version.php"; if (!isset($config_enable_setup)) { @@ -128,365 +127,239 @@ if (isset($_POST['add_database'])) { if (isset($_POST['restore'])) { - // ---------- Tiny atomic config helper (guarded) ---------- - if (!function_exists('setConfigFlagAtomic')) { - function setConfigFlagAtomic(string $file, string $key, $value): void { - clearstatcache(true, $file); - if (!file_exists($file)) throw new RuntimeException("config.php not found: $file"); - if (!is_readable($file)) throw new RuntimeException("config.php not readable: $file"); - if (!is_writable($file)) throw new RuntimeException("config.php not writable: $file"); - - $cfg = file_get_contents($file); - if ($cfg === false) throw new RuntimeException("Failed to read config.php"); - $cfg = str_replace("\r\n", "\n", $cfg); - - $scalar = is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true); - $line = '$' . $key . ' = ' . $scalar . ';'; - - $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; - if (preg_match($pattern, $cfg)) { - $cfg = preg_replace($pattern, $line, $cfg, 1); - } else { - if (preg_match('/\?>\s*$/', $cfg)) { - $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); - } else { - if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; - $cfg .= $line . "\n"; - } - } - - $dir = dirname($file); - $tmp = tempnam($dir, 'cfg_'); - if ($tmp === false) throw new RuntimeException("Failed to create temp file in $dir"); - if (file_put_contents($tmp, $cfg, LOCK_EX) === false) { - @unlink($tmp); - throw new RuntimeException("Failed to write temp config"); - } - $perms = @fileperms($file); - if ($perms !== false) @chmod($tmp, $perms & 0777); - if (!@rename($tmp, $file)) { - @unlink($tmp); - throw new RuntimeException("Failed to atomically replace config.php"); - } - if (function_exists('opcache_invalidate')) { - @opcache_invalidate($file, true); - } - } - } - if (!function_exists('setConfigFlag')) { - function setConfigFlag(string $file, string $key, $value): void { - $cfg = @file_get_contents($file); - if ($cfg === false) throw new RuntimeException("Cannot read $file"); - $cfg = str_replace("\r\n", "\n", $cfg); - $pattern = '/^\s*\$' . preg_quote($key, '/') . '\s*=\s*.*?;\s*$/m'; - $line = '$' . $key . ' = ' . (is_bool($value) ? ($value ? 'true' : 'false') : var_export($value, true)) . ';'; - if (preg_match($pattern, $cfg)) { - $cfg = preg_replace($pattern, $line, $cfg, 1); - } else { - if (preg_match('/\?>\s*$/', $cfg)) { - $cfg = preg_replace('/\?>\s*$/', "\n$line\n?>\n", $cfg, 1); - } else { - if ($cfg !== '' && substr($cfg, -1) !== "\n") $cfg .= "\n"; - $cfg .= $line . "\n"; - } - } - if (file_put_contents($file, $cfg, LOCK_EX) === false) { - throw new RuntimeException("Failed to update $file"); - } - if (function_exists('opcache_invalidate')) { - @opcache_invalidate($file, true); - } - } - } - - // ---------- Environment guards ---------- + // ---------- Long-running guards ---------- @set_time_limit(0); if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); } - // ---------- 1) Validate uploaded file ---------- + // ---------- Minimal helpers (scoped) ---------- + if (!function_exists('deleteDir')) { + function deleteDir($dir) { + if (!is_dir($dir)) return; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($dir); + } + } + + if (!function_exists('importSqlFile')) { + /** + * Import a SQL file via mysqli, supports DELIMITER and multi statements. + */ + function importSqlFile(mysqli $mysqli, string $path): void { + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException("SQL file not found or unreadable: $path"); + } + $fh = fopen($path, 'r'); + if (!$fh) throw new RuntimeException("Failed to open SQL file"); + + $delimiter = ';'; + $statement = ''; + + while (($line = fgets($fh)) !== false) { + $trim = trim($line); + + // Skip comments/empty + if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#')) { + continue; + } + + // Handle DELIMITER changes + if (preg_match('/^DELIMITER\s+(.+)$/i', $trim, $m)) { + $delimiter = $m[1]; + continue; + } + + $statement .= $line; + + // End of statement? + if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) { + $sql = substr($statement, 0, -strlen($delimiter)); + if ($mysqli->multi_query($sql) === false) { + fclose($fh); + throw new RuntimeException("SQL error: " . $mysqli->error); + } + // Flush any result sets + while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ } + $statement = ''; + } + } + fclose($fh); + } + } + + // ---------- 1) Validate uploaded backup ---------- if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) { die("No backup file uploaded or upload failed."); } + $file = $_FILES['backup_zip']; - - if ($file['size'] > 4 * 1024 * 1024 * 1024) { - die("Backup archive is too large."); - } - - $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."); - } - - $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); - if ($ext !== 'zip') { + $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if ($fileExt !== "zip") { die("Only .zip files are allowed."); } - // ---------- 2) Save outer zip to temp ---------- - $timestamp = date('YmdHis'); - $tempZip = tempnam(sys_get_temp_dir(), "restore_{$timestamp}_"); + // ---------- 2) Save to secure temp ---------- + $tempZip = tempnam(sys_get_temp_dir(), "restore_"); if (!move_uploaded_file($file["tmp_name"], $tempZip)) { die("Failed to save uploaded backup file."); } @chmod($tempZip, 0600); - // ---------- 3) Extract OUTER backup zip ---------- - $tempDir = sys_get_temp_dir() . "/restore_temp_" . bin2hex(random_bytes(6)); + $zip = new ZipArchive; + if ($zip->open($tempZip) !== TRUE) { + @unlink($tempZip); + die("Failed to open backup zip file."); + } + + // ---------- 3) Guard & extract OUTER zip ---------- + $tempDir = sys_get_temp_dir() . "/restore_temp_" . uniqid("", true); if (!mkdir($tempDir, 0700, true)) { + $zip->close(); @unlink($tempZip); die("Failed to create temp directory."); } - $zip = new ZipArchive; - if ($zip->open($tempZip) !== TRUE) { - @unlink($tempZip); - deleteDir($tempDir); - die("Failed to open backup zip file."); + // Zip-slip guard (outer) + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false) continue; + if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { + $zip->close(); + @unlink($tempZip); + deleteDir($tempDir); + die("Invalid file path in outer ZIP."); + } } - try { - safeExtractZip($zip, $tempDir); // helper (safe extraction) - } catch (Throwable $e) { + if (!$zip->extractTo($tempDir)) { $zip->close(); @unlink($tempZip); deleteDir($tempDir); - die("Invalid backup archive: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + die("Failed to extract backup contents."); } + $zip->close(); @unlink($tempZip); - $sqlPath = $tempDir . "/db.sql"; - $uploadsZip = $tempDir . "/uploads.zip"; // inner uploads zip - $versionTxt = $tempDir . "/version.txt"; + // ---------- 4) Restore SQL (via PHP, no CLI) ---------- + $sqlPath = "$tempDir/db.sql"; + if (file_exists($sqlPath)) { + // Drop-all first (foreign key safe) + mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0"); + $tables = mysqli_query($mysqli, "SHOW TABLES"); + if ($tables) { + while ($row = mysqli_fetch_array($tables)) { + mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $row[0] . "`"); + } + } + mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1"); - if (!is_file($sqlPath) || !is_readable($sqlPath)) { + try { + importSqlFile($mysqli, $sqlPath); + } catch (Throwable $e) { + deleteDir($tempDir); + die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + } + } else { deleteDir($tempDir); die("Missing db.sql in the backup archive."); } - if (!is_file($uploadsZip) || !is_readable($uploadsZip)) { + + // ---------- 5) Restore uploads directory ---------- + $uploadDir = rtrim(__DIR__ . "/../uploads", '/\\') . '/'; + $uploadsZip = "$tempDir/uploads.zip"; + + if (!file_exists($uploadsZip)) { deleteDir($tempDir); die("Missing uploads.zip in the backup archive."); } - // ---------- 4) Optional: version compatibility ---------- - if (defined('LATEST_DATABASE_VERSION') && is_file($versionTxt)) { - $txt = @file_get_contents($versionTxt) ?: ''; - if (preg_match('/^Database Version:\s*(.+)$/mi', $txt, $m)) { - $backupVersion = trim($m[1]); - $running = LATEST_DATABASE_VERSION; - if (version_compare($backupVersion, $running, '>')) { - deleteDir($tempDir); - die("Backup schema ($backupVersion) is newer than this app ($running). Please upgrade ITFlow first, then retry restore."); - } - } - } - - // ---------- 5) Restore SQL ---------- - 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)."`"); - } - } - mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1"); - - try { - importSqlFile($mysqli, $sqlPath); // helper - } catch (Throwable $e) { + $uploads = new ZipArchive; + if ($uploads->open($uploadsZip) !== TRUE) { deleteDir($tempDir); - die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8')); + die("Failed to open uploads.zip in backup."); } - // ---------- 6) Restore UPLOADS: DELETE existing, then REPLACE ---------- -$appRoot = realpath(__DIR__ . "/.."); -if ($appRoot === false) { - deleteDir($tempDir); - die("Failed to resolve app root."); -} - -$uploadDir = $appRoot . "/uploads"; - -// 6.a Extract inner uploads.zip to staging (with validation & scan report) -$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."); -} - -$scan = extractUploadsZipWithValidationReport($uz, $staging, [ - 'max_file_bytes' => 200 * 1024 * 1024, - '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 (!$scan['ok']) { - $lines = ["Unsafe file(s) detected in uploads.zip:"]; - foreach ($scan['issues'] as $issue) { - $p = htmlspecialchars($issue['path'], ENT_QUOTES, 'UTF-8'); - $r = htmlspecialchars($issue['reason'], ENT_QUOTES, 'UTF-8'); - $lines[] = "• {$p} — {$r}"; - } - deleteDir($staging); - deleteDir($tempDir); - $_SESSION['alert_message'] = nl2br(implode("\n", $lines)); - header("Location: ?restore"); - exit; -} - -// 6.b Determine the actual content root inside staging - // If the inner archive’s top-level is exactly "uploads/", use that; otherwise use staging itself. - $contentRoot = $staging; - $topEntries = []; - if ($dh = opendir($staging)) { - while (($e = readdir($dh)) !== false) { - if ($e === '.' || $e === '..') continue; - $topEntries[] = $e; - } - closedir($dh); - } - sort($topEntries); - - if (count($topEntries) === 1) { - $candidate = $staging . DIRECTORY_SEPARATOR . $topEntries[0]; - if (is_dir($candidate) && strtolower($topEntries[0]) === 'uploads') { - // Use the "uploads" directory directly without moving/deleting staging yet - $contentRoot = $candidate; - } - } - - // 6.c Sanity check: content root must have files - $hasFiles = false; - $ritCheck = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($contentRoot, FilesystemIterator::SKIP_DOTS) - ); - foreach ($ritCheck as $f) { - if ($f->isFile()) { $hasFiles = true; break; } - } - if (!$hasFiles) { - deleteDir($staging); - deleteDir($tempDir); - die("Uploads restore failed: extracted content is empty (inner uploads.zip may be malformed or fully blocked)."); - } - - // 6.d Remove current /uploads and replace with restored content - if (is_dir($uploadDir)) { - deleteDir($uploadDir, $appRoot); // guarded delete under app root - } - - // If contentRoot IS the staging root, try a simple rename - $renameSource = ($contentRoot === $staging) ? $staging : $contentRoot; - $renameOk = @rename($renameSource, $uploadDir); - - // If we used the child "uploads" dir as contentRoot, staging still exists with an empty top; clean it later - if (!$renameOk) { - // Fallback: copy tree - if (!mkdir($uploadDir, 0750, true)) { - deleteDir($staging); + // Zip-slip guard (inner) + for ($i = 0; $i < $uploads->numFiles; $i++) { + $name = $uploads->getNameIndex($i); + if ($name === false) continue; + if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) { + $uploads->close(); deleteDir($tempDir); - die("Failed to create uploads directory for placement."); + die("Invalid file path in uploads.zip."); } + } - $rit = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($contentRoot, FilesystemIterator::SKIP_DOTS), + // Ensure uploads dir exists then clean it + if (!is_dir($uploadDir)) { + if (!mkdir($uploadDir, 0750, true)) { + $uploads->close(); + deleteDir($tempDir); + die("Failed to create uploads directory."); + } + } else { + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + } + + // Extract uploads.zip directly into /uploads (your original, working behavior) + if (!$uploads->extractTo($uploadDir)) { + $uploads->close(); + deleteDir($tempDir); + die("Failed to extract uploads.zip into uploads directory."); + } + $uploads->close(); + + // Verify uploads isn’t empty + $hasFiles = false; + $fileCount = 0; $dirCount = 0; + if (is_dir($uploadDir)) { + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); - foreach ($rit as $it) { - $rel = substr($it->getPathname(), strlen($contentRoot) + 1); - $dst = $uploadDir . DIRECTORY_SEPARATOR . $rel; - if ($it->isDir()) { - if (!is_dir($dst) && !mkdir($dst, 0750, true)) { - deleteDir($uploadDir); - deleteDir($staging); - deleteDir($tempDir); - die("Failed to create uploads subdir: $dst"); - } - } else { - $pdir = dirname($dst); - if (!is_dir($pdir) && !mkdir($pdir, 0750, true)) { - deleteDir($uploadDir); - deleteDir($staging); - deleteDir($tempDir); - die("Failed to create uploads parent dir: $pdir"); - } - if (!copy($it->getPathname(), $dst)) { - deleteDir($uploadDir); - deleteDir($staging); - deleteDir($tempDir); - die("Failed to place file into uploads: $rel"); - } - @chmod($dst, 0640); - } - } - // If we copied only the inner "uploads" folder, we still need to clean the top-level staging - deleteDir($staging); - } else { - // rename succeeded; if we renamed the child "uploads", the original staging still exists – clean it - if ($contentRoot !== $staging) { - deleteDir($staging); + foreach ($it as $node) { + if ($node->isDir()) $dirCount++; + else { $fileCount++; $hasFiles = true; } } } - - // 6.e Verify uploads now has files and report counts - $fileCount = 0; $dirCount = 0; - $ritVerify = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - foreach ($ritVerify as $it) { - if ($it->isDir()) $dirCount++; - else $fileCount++; - } - if ($fileCount === 0) { + if (!$hasFiles) { deleteDir($tempDir); - die("Uploads replace failed: resulting directory is empty."); + die("Uploads restore appears empty after extraction."); } - // ---------- 7) Log version info (optional) ---------- - if (is_file($versionTxt)) { + // ---------- 6) Optional: version info ---------- + $versionTxt = "$tempDir/version.txt"; + if (file_exists($versionTxt)) { $versionInfo = @file_get_contents($versionTxt); if ($versionInfo !== false) { logAction("Backup Restore", "Version Info", $versionInfo); } } - // ---------- 8) Cleanup temp ---------- + // ---------- 7) Cleanup temp ---------- deleteDir($tempDir); - // ---------- 9) Finalize setup flag ---------- - try { - setConfigFlagAtomic(__DIR__ . "/../config.php", "config_enable_setup", 0); - } catch (Throwable $e) { - try { - setConfigFlag(__DIR__ . "/../config.php", "config_enable_setup", 0); - if (function_exists('opcache_invalidate')) { - @opcache_invalidate(__DIR__ . "/../config.php", true); - } - } catch (\Throwable $e2) { - $_SESSION['alert_message'] = - "Restore completed, but couldn’t finalize setup flag automatically: " . - htmlspecialchars($e2->getMessage(), ENT_QUOTES, 'UTF-8'); - header("Location: ../login.php"); - exit; - } + // ---------- 8) Finalize setup flag (append safely) ---------- + $configPath = __DIR__ . "/../config.php"; + $append = "\n\$config_enable_setup = 0;\n\n"; + if (!@file_put_contents($configPath, $append, FILE_APPEND | LOCK_EX)) { + $_SESSION['alert_message'] = "Backup restored ($fileCount files, $dirCount folders), but couldn't update setup flag — please set \$config_enable_setup = 0 in config.php."; + } else { + $_SESSION['alert_message'] = "Full backup restored successfully ($fileCount files, $dirCount folders)."; } - // ---------- 10) Done ---------- - $_SESSION['alert_message'] = "Full backup restored successfully. Uploads directory was replaced."; + // ---------- 9) Done ---------- header("Location: ../login.php"); exit; } From 2ee70fd3a84b4796bed175ec407ab4e4f24cb334 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 19:23:48 -0400 Subject: [PATCH 010/112] Update .htaccess --- uploads/.htaccess | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/uploads/.htaccess b/uploads/.htaccess index 0c5dab21..da91030a 100644 --- a/uploads/.htaccess +++ b/uploads/.htaccess @@ -1,11 +1,7 @@ -php_flag engine off -RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 -RemoveType application/x-httpd-php - - Deny from all - Options -ExecCGI -AddHandler cgi-script .cgi .pl .sh .bash .zsh - - Deny from all - \ No newline at end of file +php_flag engine off +RemoveHandler .php .phtml .phar .phps +RemoveType .php .phtml .phar .phps + + Require all denied + From d97654581bf9bdd59552d2bbd5edffb691e1bcee Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sun, 12 Oct 2025 13:34:27 -0400 Subject: [PATCH 011/112] Add 30 Day wording to Expiring Domain and Certificates in dashboard --- agent/dashboard.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/agent/dashboard.php b/agent/dashboard.php index 4f38035c..5a6223b4 100644 --- a/agent/dashboard.php +++ b/agent/dashboard.php @@ -660,7 +660,7 @@ if ($user_config_dashboard_technical_enable == 1) {

-

Expiring Domains

+

Expiring Domains 30 Day

@@ -673,7 +673,7 @@ if ($user_config_dashboard_technical_enable == 1) {

-

Expiring Certificates

+

Expiring Certificates30 Day

From 39c9c695f177d6d6f15b53ecd60de7be96c1ad29 Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 14 Oct 2025 15:59:29 -0400 Subject: [PATCH 012/112] Allow searching tickets with ticketprefix and number combo in Global search --- agent/global_search.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/global_search.php b/agent/global_search.php index 527a34c5..e47a7113 100644 --- a/agent/global_search.php +++ b/agent/global_search.php @@ -85,6 +85,7 @@ if (isset($_GET['query'])) { LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id WHERE ticket_archived_at IS NULL AND (ticket_subject LIKE '%$query%' + OR CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' OR ticket_number = '$ticket_num_query') $access_permission_query ORDER BY ticket_id DESC LIMIT 5" @@ -93,7 +94,8 @@ if (isset($_GET['query'])) { $sql_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets LEFT JOIN clients ON recurring_ticket_client_id = client_id WHERE (recurring_ticket_subject LIKE '%$query%' - OR recurring_ticket_details LIKE '%$query%') + OR recurring_ticket_details LIKE '%$query%' + CONCAT(recurring_ticket_prefix,recurring_ticket_number) LIKE '%$q%') $access_permission_query ORDER BY recurring_ticket_id DESC LIMIT 5" ); From 61d15cbf9ec02240d680b74351f2d7a0d01bae3b Mon Sep 17 00:00:00 2001 From: johnnyq Date: Tue, 14 Oct 2025 16:07:08 -0400 Subject: [PATCH 013/112] Remove non existent seatch column recurring ticket prefix --- agent/global_search.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/agent/global_search.php b/agent/global_search.php index e47a7113..758067bc 100644 --- a/agent/global_search.php +++ b/agent/global_search.php @@ -85,7 +85,7 @@ if (isset($_GET['query'])) { LEFT JOIN ticket_statuses ON ticket_status = ticket_status_id WHERE ticket_archived_at IS NULL AND (ticket_subject LIKE '%$query%' - OR CONCAT(ticket_prefix,ticket_number) LIKE '%$q%' + OR CONCAT(ticket_prefix,ticket_number) LIKE '%$query%' OR ticket_number = '$ticket_num_query') $access_permission_query ORDER BY ticket_id DESC LIMIT 5" @@ -94,8 +94,7 @@ if (isset($_GET['query'])) { $sql_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets LEFT JOIN clients ON recurring_ticket_client_id = client_id WHERE (recurring_ticket_subject LIKE '%$query%' - OR recurring_ticket_details LIKE '%$query%' - CONCAT(recurring_ticket_prefix,recurring_ticket_number) LIKE '%$q%') + OR recurring_ticket_details LIKE '%$query%') $access_permission_query ORDER BY recurring_ticket_id DESC LIMIT 5" ); From db7f8501d01a3176a8cb03d1ae357fc1fcb18cd1 Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 09:18:53 +0100 Subject: [PATCH 014/112] When archiving a client, cancel recurring invoices --- agent/post/client.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/agent/post/client.php b/agent/post/client.php index f34fcbba..d47b290a 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -171,11 +171,20 @@ if (isset($_GET['archive_client'])) { $client_id = intval($_GET['archive_client']); + // Archive client + mysqli_query($mysqli, "UPDATE clients SET client_archived_at = NOW() WHERE client_id = $client_id"); + + // Stop recurring invoices + $sql_recurring_invoices = mysqli_query($mysqli, "SELECT * FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id AND recurring_invoice_status = 1"); + while ($row = mysqli_fetch_array($sql_recurring_invoices)) { + $recurring_invoice_id = intval($row['recurring_invoice_id']); + mysqli_query($mysqli,"UPDATE recurring_invoices SET recurring_invoice_status = 0 WHERE recurring_invoice_id = $recurring_invoice_id AND recurring_invoice_client_id = $client_id"); + mysqli_query($mysqli,"INSERT INTO history SET history_status = '$status', history_description = 'Recurring Invoice inactive as client archived', history_recurring_invoice_id = $recurring_invoice_id"); + } + // Get Client Name $client_name = sanitizeInput(getFieldById('clients', $client_id, 'client_name')); - mysqli_query($mysqli, "UPDATE clients SET client_archived_at = NOW() WHERE client_id = $client_id"); - logAction("Client", "Archive", "$session_name archived client $client_name", $client_id, $client_id); flash_alert("Client $client_name archived", 'error'); From 9a5a4be64af24d4168b3d6a3b06dfd373b63540e Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 09:20:08 +0100 Subject: [PATCH 015/112] When archiving a client, cancel recurring invoices --- agent/post/client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/post/client.php b/agent/post/client.php index d47b290a..e1726f9b 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -179,7 +179,7 @@ if (isset($_GET['archive_client'])) { while ($row = mysqli_fetch_array($sql_recurring_invoices)) { $recurring_invoice_id = intval($row['recurring_invoice_id']); mysqli_query($mysqli,"UPDATE recurring_invoices SET recurring_invoice_status = 0 WHERE recurring_invoice_id = $recurring_invoice_id AND recurring_invoice_client_id = $client_id"); - mysqli_query($mysqli,"INSERT INTO history SET history_status = '$status', history_description = 'Recurring Invoice inactive as client archived', history_recurring_invoice_id = $recurring_invoice_id"); + mysqli_query($mysqli,"INSERT INTO history SET history_status = 0, history_description = 'Recurring Invoice inactive as client archived', history_recurring_invoice_id = $recurring_invoice_id"); } // Get Client Name From 21aee98f9f2191d01ade6f9cb21a134f53083169 Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 09:57:32 +0100 Subject: [PATCH 016/112] Fix checkAll ticket box not showing when status wasn't set - should only be hidden for the closed view --- agent/ticket_list.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/ticket_list.php b/agent/ticket_list.php index 80c891a1..c001a134 100644 --- a/agent/ticket_list.php +++ b/agent/ticket_list.php @@ -9,7 +9,7 @@ - +
From b7a9f9ea386b3e1424b5b5bb62d5ff565e6e5678 Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 10:12:14 +0100 Subject: [PATCH 017/112] When exporting credential info, include the TOTP secret --- agent/post/credential.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/agent/post/credential.php b/agent/post/credential.php index 392aab9d..ce41fa56 100644 --- a/agent/post/credential.php +++ b/agent/post/credential.php @@ -317,8 +317,6 @@ if (isset($_POST['export_credentials_csv'])) { //get records from database $sql = mysqli_query($mysqli,"SELECT * FROM credentials LEFT JOIN clients ON client_id = credential_client_id WHERE credential_archived_at IS NULL $client_query ORDER BY credential_name ASC"); - $row = mysqli_fetch_array($sql); - $num_rows = mysqli_num_rows($sql); if ($num_rows > 0) { @@ -331,14 +329,14 @@ if (isset($_POST['export_credentials_csv'])) { $f = fopen('php://memory', 'w'); //set column headers - $fields = array('Name', 'Description', 'Username', 'Password', 'URI'); + $fields = array('Name', 'Description', 'Username', 'Password', 'TOTP', 'URI'); fputcsv($f, $fields, $delimiter, $enclosure, $escape); //output each row of the data, format line as csv and write to file pointer while($row = mysqli_fetch_assoc($sql)){ $credential_username = decryptCredentialEntry($row['credential_username']); $credential_password = decryptCredentialEntry($row['credential_password']); - $lineData = array($row['credential_name'], $row['credential_description'], $credential_username, $credential_password, $row['credential_uri']); + $lineData = array($row['credential_name'], $row['credential_description'], $credential_username, $credential_password, $row['credential_otp_secret'], $row['credential_uri']); fputcsv($f, $lineData, $delimiter, $enclosure, $escape); } From 0bb7d24e07f6660bb82c9103b9243d60dad6c1de Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 10:18:44 +0100 Subject: [PATCH 018/112] Allow importing TOTP credential info --- agent/post/credential.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/agent/post/credential.php b/agent/post/credential.php index ce41fa56..a701d125 100644 --- a/agent/post/credential.php +++ b/agent/post/credential.php @@ -388,7 +388,7 @@ if (isset($_POST["import_credentials_csv"])) { //(Else)Check column count $f = fopen($file_name, "r"); $f_columns = fgetcsv($f, 1000, ","); - if (!$error & count($f_columns) != 5) { + if (!$error & count($f_columns) != 6) { $error = true; flash_alert("Bad column count.", 'error'); } @@ -401,31 +401,40 @@ if (isset($_POST["import_credentials_csv"])) { $duplicate_count = 0; while(($column = fgetcsv($file, 1000, ",")) !== false){ $duplicate_detect = 0; + // Name if (isset($column[0])) { $name = sanitizeInput($column[0]); if (mysqli_num_rows(mysqli_query($mysqli,"SELECT * FROM credentials WHERE credential_name = '$name' AND credential_client_id = $client_id")) > 0){ $duplicate_detect = 1; } } + // Desc if (isset($column[1])) { $description = sanitizeInput($column[1]); } + // User if (isset($column[2])) { $username = sanitizeInput(encryptCredentialEntry($column[2])); } + // Pass if (isset($column[3])) { $password = sanitizeInput(encryptCredentialEntry($column[3])); } + // OTP if (isset($column[4])) { - $uri = sanitizeInput($column[4]); + $totp = sanitizeInput($column[4]); + } + // URL + if (isset($column[4])) { + $uri = sanitizeInput($column[5]); } // Check if duplicate was detected if ($duplicate_detect == 0){ //Add - mysqli_query($mysqli,"INSERT INTO credentials SET credential_name = '$name', credential_description = '$description', credential_uri = '$uri', credential_username = '$username', credential_password = '$password', credential_client_id = $client_id"); + mysqli_query($mysqli,"INSERT INTO credentials SET credential_name = '$name', credential_description = '$description', credential_uri = '$uri', credential_username = '$username', credential_password = '$password', credential_otp_secret = '$totp', credential_client_id = $client_id"); $row_count = $row_count + 1; - }else{ + } else { $duplicate_count = $duplicate_count + 1; } } @@ -453,7 +462,7 @@ if (isset($_GET['download_credentials_csv_template'])) { $f = fopen('php://memory', 'w'); //set column headers - $fields = array('Name', 'Description', 'Username', 'Password', 'URI'); + $fields = array('Name', 'Description', 'Username', 'Password', 'TOTP', 'URI'); fputcsv($f, $fields, $delimiter); //move back to beginning of file From 99ccb12b8cc7971463f914744632577d19894d0e Mon Sep 17 00:00:00 2001 From: wrongecho Date: Wed, 15 Oct 2025 10:31:59 +0100 Subject: [PATCH 019/112] Allow importing TOTP credential info --- agent/modals/credential/credential_import.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/modals/credential/credential_import.php b/agent/modals/credential/credential_import.php index 68d0d5a3..f19e8b38 100644 --- a/agent/modals/credential/credential_import.php +++ b/agent/modals/credential/credential_import.php @@ -12,7 +12,7 @@