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

This commit is contained in:
johnnyq 2025-10-09 12:28:38 -04:00
parent fbf3346052
commit ed589ef65b
4 changed files with 780 additions and 210 deletions

View File

@ -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']);

View File

@ -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'])) {
<h3 class="card-title"><i class="fas fa-fw fa-database mr-2"></i>Restore from Backup</h3>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<?php
// generate CSRF token for this form
if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); }
?>
<form method="post" enctype="multipart/form-data" autocomplete="off">
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($_SESSION['csrf']); ?>">
<label>Restore ITFlow Backup (.zip)</label>
<input type="file" name="backup_zip" accept=".zip" required>
<p class="text-muted mt-2 mb-0"><small>Large restores may take several minutes. Do not close this page.</small></p>
<hr>
<button type="submit" name="restore" class="btn btn-primary text-bold">
Restore Backup<i class="fas fa-fw fa-upload ml-2"></i>

347
setup/setup_functions.php Normal file
View File

@ -0,0 +1,347 @@
<?php
// --- Helpers for restore ---
/** 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()); }
}
@rmdir($dir);
}
/** 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);
}
// flush any result sets
while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ }
$statement = '';
}
}
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)) {
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");
}
// 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");
}
// 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");
}
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);
// Final boundary check
$real = realpath($target);
if ($real === false || str_starts_with($real, $rootReal) === false) {
@unlink($target);
throw new RuntimeException("Path traversal detected for $name");
}
// Disallow symlinks (in case zip contained one)
if (is_link($real)) {
@unlink($real);
throw new RuntimeException("Symlink detected in archive: $name");
}
}
}
}
/** 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");
}
}
/**
* 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;
// Pull last extension
$ext = strtolower(pathinfo($lower, PATHINFO_EXTENSION));
if (in_array($ext, $blockedExts, true)) return true;
// 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;
}
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) ?: '';
// Quick MIME-based blocks (dont 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) {
fseek($fp, -4096, SEEK_END);
$tail = fread($fp, 4096) ?: '';
}
fclose($fp);
$blob = $head . $tail;
// Block common code markers / execution hints
$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(',
];
foreach ($markers as $m) {
if (stripos($blob, $m) !== false) return true;
}
return false;
}
/**
* Extract uploads.zip to $destDir with validation & reporting.
* - Validates each entry (boundary, extension, MIME/signatures, size)
* - Collects all issues instead of failing fast
* - If any issues are found, NOTHING is extracted and an array of problems is returned
*
* @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
$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 = [];
$staging = $destDir; // caller gives us a staging dir (empty)
$rootReal = realpath($staging);
if ($rootReal === false) {
if (!mkdir($staging, 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;
}
$isDir = str_ends_with($name, '/');
if ($isDir) {
// Well create directories in the commit phase if no issues were found
continue;
}
$stream = $zip->getStream($name);
if ($stream === false) {
$issues[] = ['path' => $name, 'reason' => 'Unable to read entry from ZIP'];
continue;
}
// 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']]];
}
@unlink($p['tmp']);
}
// 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']]];
}
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];
}

11
uploads/.htaccess Normal file
View File

@ -0,0 +1,11 @@
php_flag engine off
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8
RemoveType application/x-httpd-php
<FilesMatch "\.(php|phtml|php[0-9])$">
Deny from all
</FilesMatch>
Options -ExecCGI
AddHandler cgi-script .cgi .pl .sh .bash .zsh
<FilesMatch "\.(cgi|pl|sh|bash|zsh)$">
Deny from all
</FilesMatch>