mirror of https://github.com/itflow-org/itflow
another Attempt at restore
This commit is contained in:
parent
1d9429b762
commit
986f688468
362
setup/index.php
362
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,7 +257,6 @@ if (isset($_POST['restore'])) {
|
|||
$zip->close();
|
||||
@unlink($tempZip);
|
||||
|
||||
// Expected paths inside extracted archive
|
||||
$sqlPath = $tempDir . "/db.sql";
|
||||
$uploadsZip = $tempDir . "/uploads.zip"; // inner uploads zip
|
||||
$versionTxt = $tempDir . "/version.txt";
|
||||
|
|
@ -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)) {
|
||||
if ($appRoot === false) {
|
||||
deleteDir($tempDir);
|
||||
die("Failed to create uploads directory.");
|
||||
}
|
||||
$uploadDir = realpath($uploadDir);
|
||||
}
|
||||
if ($uploadDir === false || strpos($uploadDir, $appRoot) !== 0) {
|
||||
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);
|
||||
}
|
||||
$old = $staging;
|
||||
$staging = $stagingPromoted;
|
||||
deleteDir($old);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: staging must have content
|
||||
if (countFilesRecursive($staging) === 0) {
|
||||
$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("Uploads restore failed: extracted staging is empty. The inner uploads.zip may be malformed or all files were blocked.");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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).");
|
||||
// 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: staging is empty (inner uploads.zip may be malformed or fully blocked).");
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// Rollback to pre-merge state
|
||||
try {
|
||||
if (is_dir($backupOld)) {
|
||||
// Restore snapshot over current uploads
|
||||
// --- 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.");
|
||||
}
|
||||
$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);
|
||||
recursiveCopy($backupOld, $uploadDir);
|
||||
}
|
||||
} catch (\Throwable $rollbackErr) {
|
||||
// Best effort rollback
|
||||
}
|
||||
deleteDir($staging);
|
||||
deleteDir($tempDir);
|
||||
die("Uploads restore failed during merge: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||||
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);
|
||||
}
|
||||
}
|
||||
deleteDir($staging);
|
||||
}
|
||||
|
||||
// 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);
|
||||
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'] =
|
||||
"Backup restored, but couldn’t finalize setup flag automatically: " .
|
||||
htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
|
||||
"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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,60 @@
|
|||
<?php
|
||||
// --- Helpers for restore ---
|
||||
/**
|
||||
* ITFlow Restore Helpers (hardened)
|
||||
* - Safe recursive delete with optional root guard
|
||||
* - SQL import with DELIMITER and EOF handling
|
||||
* - Safe ZIP extraction (blocks traversal, symlinks, junk files)
|
||||
* - Config setters (idempotent + atomic)
|
||||
* - Uploads ZIP validator/extractor with MIME/signature scan and size caps
|
||||
* - Dangerous extension detector & executable content heuristic
|
||||
*/
|
||||
|
||||
// ------------------------------
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a directory recursively (safe, no symlinks followed). */
|
||||
function deleteDir(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
if ($item->isDir()) { @rmdir($item->getPathname()); }
|
||||
else { @unlink($item->getPathname()); }
|
||||
$path = $item->getPathname();
|
||||
if ($item->isDir()) { @rmdir($path); }
|
||||
else { @unlink($path); }
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a SQL file via mysqli, supporting custom DELIMITER and multi statements. */
|
||||
function importSqlFile(mysqli $mysqli, string $path): void {
|
||||
// ------------------------------
|
||||
// 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");
|
||||
}
|
||||
|
|
@ -47,72 +85,116 @@ function importSqlFile(mysqli $mysqli, string $path): void {
|
|||
$sql = substr($statement, 0, -strlen($delimiter));
|
||||
if ($mysqli->multi_query($sql) === false) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException("SQL error: ".$mysqli->error);
|
||||
throw new RuntimeException("SQL error: " . $mysqli->error);
|
||||
}
|
||||
// flush any result sets
|
||||
while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)) {
|
||||
// ------------------------------
|
||||
// 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);
|
||||
if ($rootReal === false) {
|
||||
throw new RuntimeException("Failed to resolve destination");
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
// Reject absolute or drive-lettered paths
|
||||
// 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 path in zip: $name");
|
||||
throw new RuntimeException("Invalid absolute/drive path in zip: $name");
|
||||
}
|
||||
|
||||
// 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");
|
||||
// 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;
|
||||
|
||||
// Directories end with '/'
|
||||
$isDir = str_ends_with($name, '/');
|
||||
$target = $rootReal . DIRECTORY_SEPARATOR . $safeRel;
|
||||
$parent = dirname($target);
|
||||
|
||||
// Read entry
|
||||
$fp = $zip->getStream($name);
|
||||
if ($fp === false) {
|
||||
if ($isDir) continue;
|
||||
throw new RuntimeException("Failed to read $name from zip");
|
||||
// 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)) {
|
||||
fclose($fp);
|
||||
throw new RuntimeException("Failed to mkdir $target");
|
||||
}
|
||||
fclose($fp);
|
||||
} else {
|
||||
$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");
|
||||
}
|
||||
$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,64 +203,150 @@ 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 {
|
||||
// ------------------------------
|
||||
// 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");
|
||||
$pattern = '/^\s*\$'.preg_quote($key, '/').'\s*=\s*.*?;\s*$/m';
|
||||
$line = '$'.$key.' = '.(is_bool($value)? ($value?'true':'false') : var_export($value,true)).";\n";
|
||||
$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);
|
||||
$cfg = preg_replace($pattern, $line, $cfg, 1);
|
||||
} else {
|
||||
$cfg .= "\n".$line;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
// ------------------------------
|
||||
// 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");
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// 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 {
|
||||
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;
|
||||
|
||||
// Pull last extension
|
||||
// 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 (in_array($ext, $blockedExts, true)) return true;
|
||||
if ($ext === '') return false;
|
||||
|
||||
// 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)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
// ------------------------------
|
||||
// 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 {
|
||||
// Use finfo to detect executable/script mimetypes
|
||||
function contentLooksExecutable(string $tmpPath): bool {
|
||||
$mime = '';
|
||||
if (class_exists('finfo')) {
|
||||
$fi = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $fi->file($tmpPath) ?: '';
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if ($stat && ($stat['size'] ?? 0) > 4096) {
|
||||
fseek($fp, -4096, SEEK_END);
|
||||
$tail = fread($fp, 4096) ?: '';
|
||||
}
|
||||
|
|
@ -186,22 +354,27 @@ function contentLooksExecutable(string $tmpPath): bool {
|
|||
|
||||
$blob = $head . $tail;
|
||||
|
||||
// Block common code markers / execution hints
|
||||
// Execution markers (limited to reduce false positives)
|
||||
$markers = [
|
||||
'<?php', '<?=',
|
||||
'#!/usr/bin/env php', '#!/usr/bin/php',
|
||||
'#!/bin/bash', '#!/bin/sh', '#!/usr/bin/env bash',
|
||||
'eval(', 'assert(', 'base64_decode(',
|
||||
'shell_exec(', 'proc_open(', 'popen(', 'system(', 'passthru(',
|
||||
'eval(', 'assert(', 'base64_decode(',
|
||||
];
|
||||
foreach ($markers as $m) {
|
||||
if (stripos($blob, $m) !== false) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
// ------------------------------
|
||||
// extractUploadsZipWithValidationReport
|
||||
// ------------------------------
|
||||
if (!function_exists('extractUploadsZipWithValidationReport')) {
|
||||
/**
|
||||
* Extract uploads.zip to $destDir with validation & reporting.
|
||||
* - Validates each entry (boundary, extension, MIME/signatures, size)
|
||||
* - Collects all issues instead of failing fast
|
||||
|
|
@ -209,8 +382,9 @@ function contentLooksExecutable(string $tmpPath): bool {
|
|||
*
|
||||
* @return array{ok: bool, issues?: array<int, array{path: string, reason: string}>}
|
||||
*/
|
||||
function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir, array $options = []): array {
|
||||
$maxFileBytes = $options['max_file_bytes'] ?? (200 * 1024 * 1024); // 200MB
|
||||
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',
|
||||
|
|
@ -218,33 +392,37 @@ function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir,
|
|||
];
|
||||
|
||||
$issues = [];
|
||||
$staging = $destDir; // caller gives us a staging dir (empty)
|
||||
$rootReal = realpath($staging);
|
||||
if ($rootReal === false) {
|
||||
if (!mkdir($staging, 0700, true)) {
|
||||
if (!is_dir($destDir) && !mkdir($destDir, 0700, true)) {
|
||||
return ['ok' => false, 'issues' => [['path' => '(staging)', 'reason' => 'Failed to create staging directory']]];
|
||||
}
|
||||
$rootReal = realpath($staging);
|
||||
$rootReal = realpath($destDir);
|
||||
if ($rootReal === false) {
|
||||
return ['ok' => false, 'issues' => [['path' => '(staging)', 'reason' => 'Failed to resolve staging directory']]];
|
||||
}
|
||||
|
||||
// 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' ], ... ]
|
||||
$pending = []; // list of ['tmp','target','name']
|
||||
$totalBytes = 0;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
|
||||
// Reject absolute/drive-lettered paths
|
||||
// Skip junk/system entries
|
||||
if ($name === '' ||
|
||||
str_starts_with($name, '__MACOSX/') ||
|
||||
preg_match('#/\.DS_Store$#i', $name) ||
|
||||
preg_match('#/Thumbs\.db$#i', $name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absolute / drive-lettered paths
|
||||
if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
|
||||
$issues[] = ['path' => $name, 'reason' => 'Invalid absolute or drive path'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$isDir = str_ends_with($name, '/');
|
||||
if ($isDir) {
|
||||
// We’ll create directories in the commit phase if no issues were found
|
||||
continue;
|
||||
}
|
||||
// Directories: defer creation to commit phase
|
||||
if (str_ends_with($name, '/')) continue;
|
||||
|
||||
$stream = $zip->getStream($name);
|
||||
if ($stream === false) {
|
||||
|
|
@ -252,27 +430,30 @@ function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir,
|
|||
continue;
|
||||
}
|
||||
|
||||
// 1) Extension and double-extension checks
|
||||
// Extension/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
|
||||
// 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;
|
||||
$bytes = 0; $err = null;
|
||||
while (!feof($stream)) {
|
||||
$chunk = fread($stream, 1 << 15);
|
||||
if ($chunk === false) { $err = 'Read error while extracting'; break; }
|
||||
$bytes += strlen($chunk);
|
||||
$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);
|
||||
|
|
@ -284,34 +465,32 @@ function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir,
|
|||
continue;
|
||||
}
|
||||
|
||||
// 3) MIME + signature checks
|
||||
// MIME/signature check
|
||||
if (contentLooksExecutable($tmp)) {
|
||||
@unlink($tmp);
|
||||
$issues[] = ['path' => $name, 'reason' => 'Executable/script content detected'];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Record as candidate for commit
|
||||
// Record as candidate
|
||||
$target = $rootReal . DIRECTORY_SEPARATOR . $name;
|
||||
$pending[] = ['tmp' => $tmp, 'target' => $target, 'name' => $name];
|
||||
}
|
||||
|
||||
// If any issues, cleanup temps and return report
|
||||
// Any issues? clean up and 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
|
||||
// Commit: create dirs and move files
|
||||
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']); }
|
||||
|
|
@ -321,14 +500,12 @@ function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir,
|
|||
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']]];
|
||||
}
|
||||
@unlink($p['tmp']);
|
||||
}
|
||||
|
||||
// Permissions & final checks
|
||||
@chmod($p['target'], 0640);
|
||||
$real = realpath($p['target']);
|
||||
if ($real === false || strpos($real, $rootReal) !== 0) {
|
||||
|
|
@ -344,4 +521,5 @@ function extractUploadsZipWithValidationReport(ZipArchive $zip, string $destDir,
|
|||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue