Encrypted backups with types, scheduling and CLI restore

Backups are now AES-256 encrypted zips in three types (full, database
only, master key), catalogued in a new backups table, built by cron rather
than the web request, and kept under uploads/backups with retention in the
nightly job. The encryption key is one value per install held in config.php,
never in the database and never in the file name.

Restore is shared by the setup wizard and the new scripts/restore_cli.php,
which is the only path without an upload size limit. It verifies the key and
unpacks the archive before dropping anything, and dumps the current database
first so a failed import is rolled back.

A backup dumps, zips and encrypts for minutes without issuing a query, so on
a server with a short wait_timeout the connection is closed underneath it and
the UPDATE marking the backup complete is what fails - long after the archive
was written correctly. The connection is now held open for the job and
re-established before any write that follows long file work, including the
database phase of a restore. Retention recovers rows a dropped connection
left behind: still Running after six hours becomes Complete if the archive is
on disk, Failed if it is not.

cron.php's own failure path is hardened to match. It recorded job failures
through the same connection the failing job had just killed, so an uncaught
exception ended the dispatch and no trace of the original error survived.
Failures now also echo to stdout, so cron mails something useful when the
database is unreachable.

Security: the setup wizard's restore step is now closed on any install that
has users, whatever config.php says. $config_enable_setup defaulted to
enabled when the flag was absent, and the flag is only written at the end of
a successful install, so an install abandoned partway left an unauthenticated
endpoint that would drop every table, import an attacker-supplied archive,
and overwrite uploads/ including the .htaccess that stops PHP running there.
Affects 26.07 and earlier. Restoring over a live install is now CLI only.
This commit is contained in:
johnnyq
2026-07-31 16:18:20 -04:00
parent 6a5cf6704a
commit ae468d6cee
18 changed files with 2313 additions and 546 deletions

View File

@@ -1,303 +1,75 @@
<?php
/*
* ITFlow - GET/POST request handler for DB / master key backup
* Rewritten with streaming SQL dump, component checksums, safer zipping, and better headers.
* ITFlow - GET/POST request handler for backups
*
* Archives are not built here. Everything on this page records intent and lets
* cron/backup.php do the work - see backupQueue() for why.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
require_once "../includes/app_version.php";
// --- Optional performance levers for big backups ---
@set_time_limit(0);
if (function_exists('ini_set')) {
@ini_set('memory_limit', '1024M');
}
/**
* Write a line to a file handle with newline.
*/
function writeLine($fh, string $s): void {
fwrite($fh, $s);
fwrite($fh, PHP_EOL);
}
/**
* 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 dumpDatabase(mysqli $mysqli, string $sqlFile): void {
$fh = fopen($sqlFile, 'wb');
if (!$fh) {
http_response_code(500);
exit("Cannot open dump file");
}
// Preamble
writeLine($fh, "-- UTF-8 + Foreign Key Safe Dump");
writeLine($fh, "SET NAMES 'utf8mb4';");
writeLine($fh, "SET FOREIGN_KEY_CHECKS = 0;");
writeLine($fh, "SET UNIQUE_CHECKS = 0;");
writeLine($fh, "SET AUTOCOMMIT = 0;");
writeLine($fh, "");
// Gather tables and views
$tables = [];
$views = [];
$res = $mysqli->query("SHOW FULL TABLES");
if (!$res) {
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();
// --- 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();
writeLine($fh, "-- ----------------------------");
writeLine($fh, "-- Table structure for `{$table}`");
writeLine($fh, "-- ----------------------------");
writeLine($fh, "DROP TABLE IF EXISTS `{$table}`;");
writeLine($fh, $createSQL . ";");
writeLine($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) {
writeLine($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)
);
writeLine($fh, "INSERT INTO `{$table}` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");");
}
$dataRes->close();
if ($wroteHeader) writeLine($fh, "");
}
}
// --- 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();
writeLine($fh, "-- ----------------------------");
writeLine($fh, "-- View structure for `{$view}`");
writeLine($fh, "-- ----------------------------");
writeLine($fh, "DROP VIEW IF EXISTS `{$view}`;");
// Ensure statement ends with semicolon
if (!str_ends_with($createView, ';')) $createView .= ';';
writeLine($fh, $createView);
writeLine($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();
writeLine($fh, "-- ----------------------------");
writeLine($fh, "-- Trigger for `{$triggerName}`");
writeLine($fh, "-- ----------------------------");
writeLine($fh, "DROP TRIGGER IF EXISTS `{$triggerName}`;");
if (!str_ends_with($createTrig, ';')) $createTrig .= ';';
writeLine($fh, $createTrig);
writeLine($fh, "");
}
}
$tRes->close();
}
// Postamble
writeLine($fh, "SET FOREIGN_KEY_CHECKS = 1;");
writeLine($fh, "SET UNIQUE_CHECKS = 1;");
writeLine($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;
}
$relativePath = substr($filePath, strlen($folderReal) + 1);
$zip->addFile($filePath, $relativePath);
}
$zip->close();
}
if (isset($_GET['download_backup'])) {
if (isset($_GET['queue_backup'])) {
validateCSRFToken();
$timestamp = date('YmdHis');
$baseName = "itflow_{$timestamp}";
$downloadName = $baseName . ".zip";
$type = strtolower(trim($_GET['queue_backup']));
// === 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); }
}
});
$error = null;
$backup_id = backupQueue($mysqli, $type, $session_name ?? 'Unknown User', $error);
// === 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);
if ($backup_id > 0) {
logAudit("Backup", "Queue", ($session_name ?? 'Unknown User') . " queued a " . backupTypeLabel($type));
flashAlert(backupTypeLabel($type) . " queued - it will start within a minute and you will be notified when it is ready.");
} else {
flashAlert($error ?? "Could not queue the backup.", 'error');
}
// === Generate SQL Dump (streaming) ===
dumpDatabase($mysqli, $sqlFile);
redirect("backup.php");
}
// === Zip the uploads folder (strict) ===
zipFolderStrict("../uploads", $uploadsZip);
if (isset($_GET['delete_backup'])) {
// === 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';
validateCSRFToken();
$dbSha = hash_file('sha256', $sqlFile) ?: 'N/A';
$upSha = hash_file('sha256', $uploadsZip) ?: 'N/A';
$backup_id = intval($_GET['delete_backup']);
$versionContent = "ITFlow Backup Metadata\n";
$versionContent .= "-----------------------------\n";
$versionContent .= "Generated: " . date('Y-m-d H:i:s') . "\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 .= "Checksums (SHA256):\n";
$versionContent .= " db.sql: $dbSha\n";
$versionContent .= " uploads.zip: $upSha\n";
$sql = mysqli_query($mysqli, "SELECT backup_file_name, backup_type FROM backups WHERE backup_id = $backup_id");
file_put_contents($versionFile, $versionContent);
@chmod($versionFile, 0600);
// === 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.");
if (mysqli_num_rows($sql) === 1) {
$row = mysqli_fetch_assoc($sql);
backupDeleteById($mysqli, $backup_id);
logAudit("Backup", "Delete", ($session_name ?? 'Unknown User') . " deleted backup " . escapeSql($row['backup_file_name']));
flashAlert("Backup deleted.");
} else {
flashAlert("Backup not found.", 'error');
}
$final->addFile($sqlFile, "db.sql");
$final->addFile($uploadsZip, "uploads.zip");
$final->addFile($versionFile, "version.txt");
$final->close();
@chmod($finalZip, 0600);
redirect("backup.php");
}
// === Serve final ZIP with a stable filename ===
header('Content-Type: application/zip');
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');
if (isset($_POST['edit_backup_settings'])) {
// Push file
flush();
$fp = fopen($finalZip, 'rb');
fpassthru($fp);
fclose($fp);
validateCSRFToken();
// Log + UX
logAudit("System", "Backup Download", ($session_name ?? 'Unknown User') . " downloaded full backup.");
flashAlert("Full backup downloaded.");
exit;
$retention_days = intval($_POST['config_backup_retention_days']);
$retention_count = intval($_POST['config_backup_retention_count']);
$cron_type = escapeSql($_POST['config_backup_cron_type']);
if ($retention_days < 0) { $retention_days = 0; }
if ($retention_count < 1) { $retention_count = 1; }
// The scheduled job runs without a session, so it can only produce the unattended types
if (!in_array($cron_type, backupUnattendedTypes(), true)) {
$cron_type = BACKUP_TYPE_FULL;
}
mysqli_query($mysqli, "UPDATE settings SET config_backup_retention_days = $retention_days, config_backup_retention_count = $retention_count, config_backup_cron_type = '$cron_type' WHERE company_id = 1");
logAudit("Backup", "Edit", ($session_name ?? 'Unknown User') . " updated the backup settings");
flashAlert("Backup settings saved.");
redirect("backup.php");
}
if (isset($_POST['backup_master_key'])) {
@@ -306,26 +78,36 @@ if (isset($_POST['backup_master_key'])) {
$password = $_POST['password'];
$sql = mysqli_query($mysqli, "SELECT * FROM users WHERE user_id = $session_user_id");
$sql = mysqli_query($mysqli, "SELECT user_password, user_specific_encryption_ciphertext FROM users WHERE user_id = $session_user_id");
$row = mysqli_fetch_assoc($sql);
if (password_verify($password, $row['user_password'])) {
$site_encryption_master_key = decryptUserSpecificKey($row['user_specific_encryption_ciphertext'], $password);
logAudit("Master Key", "Download", "$session_name retrieved the master encryption key");
appNotify("Master Key", "$session_name retrieved the master encryption key");
echo "==============================";
echo "<br>Master encryption key:<br>";
echo "<b>$site_encryption_master_key</b>";
echo "<br>==============================";
} else {
logAudit("Master Key", "Download", "$session_name attempted to retrieve the master encryption key but failed");
if (!$row || !password_verify($password, $row['user_password'])) {
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " attempted to retrieve the master encryption key but failed");
flashAlert("Incorrect password.", 'error');
redirect();
redirect("backup.php");
}
$site_encryption_master_key = decryptUserSpecificKey($row['user_specific_encryption_ciphertext'], $password);
if (empty($site_encryption_master_key)) {
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " could not unwrap the master encryption key");
flashAlert("Your password is correct but the master key could not be unwrapped from your account.", 'error');
redirect("backup.php");
}
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " retrieved the master encryption key");
appNotify("Master Key", ($session_name ?? 'Unknown User') . " retrieved the master encryption key", "/admin/backup.php");
// Written as an encrypted archive too, so it can be filed with the other backups.
// This is the one type cron can never produce - the key only exists inside a session.
$error = null;
backupCreate($mysqli, BACKUP_TYPE_MASTER_KEY, $session_name ?? 'Unknown User', 'Manual', $error, ['master_key' => $site_encryption_master_key]);
$_SESSION['backup_master_key_reveal'] = $site_encryption_master_key;
if ($error) {
flashAlert("Master key shown below, but the encrypted copy could not be written: $error", 'error');
}
redirect("backup.php");
}