From 986f6884689644ba0baf8defe08fc5256c63d4cc Mon Sep 17 00:00:00 2001 From: johnnyq Date: Thu, 9 Oct 2025 18:49:54 -0400 Subject: [PATCH] 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 +}