stage #2

Merged
ThaMunsta merged 122 commits from stage into main 2026-01-17 20:10:26 +00:00
4 changed files with 780 additions and 210 deletions
Showing only changes of commit ed589ef65b - Show all commits

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>