-
@@ -35,4 +290,3 @@ require_once "includes/inc_all_admin.php";
/, which would hand a full database dump to any agent with client
+ * read access. A backup is an admin artifact and gets an admin-only path of its own.
+ */
+
+require_once "../config.php";
+require_once "../functions.php";
+require_once "../includes/check_login.php";
+
+enforceAdminPermission();
+validateCSRFToken();
+
+if (!isset($_GET['backup_id'])) {
+ http_response_code(400);
+ exit("Backup ID required");
+}
+
+$backup_id = intval($_GET['backup_id']);
+
+$sql = mysqli_query($mysqli, "SELECT * FROM backups WHERE backup_id = $backup_id AND backup_status = 'Complete' LIMIT 1");
+
+if (mysqli_num_rows($sql) !== 1) {
+ http_response_code(404);
+ exit("Backup not found");
+}
+
+$row = mysqli_fetch_assoc($sql);
+
+$file_path = backupResolvePath($row['backup_file_name']);
+
+if ($file_path === false || !is_file($file_path)) {
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Missing' WHERE backup_id = $backup_id");
+ http_response_code(404);
+ exit("Backup file is no longer on disk");
+}
+
+$file_name = basename($file_path);
+
+logAudit("Backup", "Download", ($session_name ?? 'Unknown User') . " downloaded backup " . escapeSql($file_name));
+mysqli_query($mysqli, "UPDATE backups SET backup_downloaded_at = NOW() WHERE backup_id = $backup_id");
+
+header("Content-Type: application/zip");
+header("Content-Disposition: attachment; filename=\"$file_name\"");
+header("Content-Length: " . filesize($file_path));
+header("X-Content-Type-Options: nosniff");
+header("Cache-Control: private, no-store");
+header("Pragma: no-cache");
+
+// Clear output buffers so a multi-gigabyte archive streams instead of loading into memory
+while (ob_get_level()) {
+ ob_end_clean();
+}
+
+readfile($file_path);
+exit;
diff --git a/admin/database_updates/2.6.4.php b/admin/database_updates/2.6.4.php
new file mode 100644
index 00000000..8e90be01
--- /dev/null
+++ b/admin/database_updates/2.6.4.php
@@ -0,0 +1,38 @@
+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 "
Master encryption key:
";
- echo "
$site_encryption_master_key";
- echo "
==============================";
-
- } 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");
}
diff --git a/cron/backup.php b/cron/backup.php
new file mode 100644
index 00000000..fad9ae2d
--- /dev/null
+++ b/cron/backup.php
@@ -0,0 +1,70 @@
+ Backup is built first. Those are
+ * explicit requests and somebody is waiting on the notification.
+ */
+$queued = backupRunQueued($mysqli);
+
+if ($queued > 0) {
+ echo "Built $queued queued backup(s)\n";
+}
+
+/*
+ * Then the scheduled one. The dispatcher only calls this script when the schedule says so,
+ * so reaching here means a scheduled backup is due.
+ *
+ * Skipped if a backup of the same type already completed today - the day-match trap from
+ * CONTRIBUTING's cron rules. A second dispatch in the same day (a manual Run Now, a catch-up
+ * after downtime) must not produce a second scheduled archive.
+ */
+$type = in_array($config_backup_cron_type, backupUnattendedTypes(), true) ? $config_backup_cron_type : BACKUP_TYPE_FULL;
+$type_esc = escapeSql($type);
+
+$already = mysqli_num_rows(mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_source = 'Cron' AND backup_type = '$type_esc' AND backup_status = 'Complete' AND backup_completed_at >= CURDATE()"));
+
+if ($already > 0) {
+ echo "Scheduled backup already ran today\n";
+} else {
+ $error = null;
+ $backup_id = backupCreate($mysqli, $type, 'Cron', 'Cron', $error);
+
+ if ($backup_id > 0) {
+ echo "Scheduled " . backupTypeLabel($type) . " complete\n";
+ appNotify("Backup", "Scheduled " . backupTypeLabel($type) . " is ready to download", "/admin/backup.php");
+ logAudit("Backup", "Create", "Scheduled " . backupTypeLabel($type) . " completed");
+ } else {
+ echo "Scheduled backup FAILED: $error\n";
+ appNotify("Backup", "Scheduled backup failed: $error", "/admin/backup.php");
+ logAudit("Backup", "Create", "Scheduled backup failed: $error");
+ logApp("Backup", "error", "Scheduled backup failed: $error");
+ }
+}
diff --git a/cron/cron.php b/cron/cron.php
index 61f44fc2..4fa25c63 100644
--- a/cron/cron.php
+++ b/cron/cron.php
@@ -77,8 +77,11 @@ function cronJobClaim($mysqli, array $job): bool
$default_interval = intval($job['interval_minutes'] ?? 1);
$default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL';
+ $default_enabled = isset($job['enabled']) ? intval($job['enabled']) : 1;
+
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET
cron_job_name = '$name',
+ cron_job_enabled = $default_enabled,
cron_job_schedule = '$default_schedule',
cron_job_interval_minutes = $default_interval,
cron_job_daily_at = $default_daily_at");
@@ -156,12 +159,24 @@ function cronJobFinished($mysqli, string $job_name, string $status, ?float $dura
$error_sql = ", cron_job_last_error = '$error_text', cron_job_last_error_at = '$finished_at'";
}
- mysqli_query($mysqli, "UPDATE cron_jobs SET
- cron_job_last_finished_at = '$finished_at',
- cron_job_last_status = '$status',
- cron_job_last_duration = $duration_sql
- $error_sql
- WHERE cron_job_name = '$name'");
+ // This is the failure path. A job that killed the database connection - a long backup
+ // whose idle connection was closed, a server restart mid-cycle - must not have its
+ // bookkeeping throw on top, or an uncaught mysqli_sql_exception ends the dispatch and
+ // no record of the original failure survives anywhere.
+ if (function_exists('backupDbEnsure')) {
+ $mysqli = backupDbEnsure($mysqli);
+ }
+
+ try {
+ mysqli_query($mysqli, "UPDATE cron_jobs SET
+ cron_job_last_finished_at = '$finished_at',
+ cron_job_last_status = '$status',
+ cron_job_last_duration = $duration_sql
+ $error_sql
+ WHERE cron_job_name = '$name'");
+ } catch (Throwable $e) {
+ echo "Cron: could not record the outcome of '$job_name' - " . $e->getMessage() . "\n";
+ }
}
// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell
@@ -218,7 +233,12 @@ foreach (cronJobRegistry() as $cron_dispatch_job) {
cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason", microtime(true) - $cron_dispatch_started);
} catch (Throwable $e) {
// One job throwing is not a reason to skip the rest of the cycle
- logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
+ echo "Cron: job '{$cron_dispatch_job['name']}' failed - " . $e->getMessage() . "\n";
+ try {
+ logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
+ } catch (Throwable $log_e) {
+ // Logging the failure must never become a second, fatal failure
+ }
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed', microtime(true) - $cron_dispatch_started, $e->getMessage());
}
diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php
index 64533f2d..759047f4 100644
--- a/cron/nightly_tasks.php
+++ b/cron/nightly_tasks.php
@@ -159,6 +159,11 @@ mysqli_query($mysqli, "DELETE FROM email_queue WHERE email_queued_at < CURDATE()
// Clean-up old remember me tokens
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_created_at < CURDATE() - INTERVAL $config_login_remember_me_expire DAY");
+// Cleanup old backups, and reconcile rows whose file is gone against files with no row.
+// Retention lives here rather than in cron/backup.php so a failed backup run can never
+// delete the archive it was supposed to replace.
+backupRunRetention($mysqli);
+
// Cleanup old audit logs
mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
diff --git a/db.sql b/db.sql
index 233f391e..0d4cadc8 100644
--- a/db.sql
+++ b/db.sql
@@ -351,6 +351,31 @@ CREATE TABLE `auth_logs` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
+--
+-- Table structure for table `backups`
+--
+
+DROP TABLE IF EXISTS `backups`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8mb4 */;
+CREATE TABLE `backups` (
+ `backup_id` int(11) NOT NULL AUTO_INCREMENT,
+ `backup_type` varchar(20) NOT NULL DEFAULT 'full',
+ `backup_file_name` varchar(255) NOT NULL,
+ `backup_size` bigint(20) NOT NULL DEFAULT 0,
+ `backup_sha256` varchar(64) DEFAULT NULL,
+ `backup_status` varchar(20) NOT NULL DEFAULT 'Pending',
+ `backup_error` text DEFAULT NULL,
+ `backup_source` varchar(20) NOT NULL DEFAULT 'Manual',
+ `backup_created_by` varchar(200) DEFAULT NULL,
+ `backup_created_at` datetime NOT NULL DEFAULT current_timestamp(),
+ `backup_completed_at` datetime DEFAULT NULL,
+ `backup_downloaded_at` datetime DEFAULT NULL,
+ PRIMARY KEY (`backup_id`),
+ KEY `backup_status_created` (`backup_status`,`backup_created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
--
-- Table structure for table `budget`
--
@@ -2278,6 +2303,9 @@ CREATE TABLE `settings` (
`config_ticket_ordering` tinyint(1) NOT NULL DEFAULT 0,
`config_ticket_moving_columns` tinyint(1) NOT NULL DEFAULT 1,
`config_cron_last_dispatch_at` datetime DEFAULT NULL,
+ `config_backup_retention_days` int(11) NOT NULL DEFAULT 30,
+ `config_backup_retention_count` int(11) NOT NULL DEFAULT 5,
+ `config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full',
PRIMARY KEY (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -3122,4 +3150,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
--- Dump completed on 2026-07-31 14:15:12
+-- Dump completed on 2026-07-31 16:18:11
diff --git a/functions.php b/functions.php
index e98cb6e4..c9b05216 100644
--- a/functions.php
+++ b/functions.php
@@ -25,3 +25,4 @@ require_once __DIR__ . '/functions/payments.php';
require_once __DIR__ . '/functions/sla.php';
require_once __DIR__ . '/functions/export.php';
require_once __DIR__ . '/functions/calendar.php';
+require_once __DIR__ . '/functions/backup.php';
diff --git a/functions/backup.php b/functions/backup.php
new file mode 100644
index 00000000..09c0e0eb
--- /dev/null
+++ b/functions/backup.php
@@ -0,0 +1,1418 @@
+ Backup so it can be written down - without it a
+ * backup cannot be restored, on this server or any other.
+ *
+ * Returns an empty string if config.php could not be written, which every caller treats
+ * as a hard failure: an unencrypted backup is worse than no backup.
+ */
+function backupEncryptionKey(): string
+{
+ global $config_backup_key;
+
+ if (!empty($config_backup_key)) {
+ return $config_backup_key;
+ }
+
+ $config_file = backupAppRoot() . "/config.php";
+
+ if (!is_writable($config_file)) {
+ return '';
+ }
+
+ $key = randomString(32);
+
+ // Re-read rather than trusting the global: another request may have generated one
+ // between the check above and here, and two keys would orphan the first backup.
+ $existing = @file_get_contents($config_file);
+ if ($existing !== false && preg_match('/\$config_backup_key\s*=\s*[\'"]([^\'"]+)[\'"]/', $existing, $match)) {
+ $config_backup_key = $match[1];
+ return $config_backup_key;
+ }
+
+ $line = "\n// Backup encryption key - keep a copy somewhere safe, backups cannot be restored without it\n";
+ $line .= "\$config_backup_key = '" . $key . "';\n";
+
+ if (@file_put_contents($config_file, $line, FILE_APPEND | LOCK_EX) === false) {
+ return '';
+ }
+
+ $config_backup_key = $key;
+
+ return $key;
+}
+
+/**
+ * Keep the database handle usable across long stretches of non-database work.
+ *
+ * A backup dumps, zips and encrypts for minutes at a time without issuing a single query.
+ * The connection is idle throughout, and a server with a short wait_timeout closes it - so
+ * the tiny UPDATE that marks the backup complete is the thing that fails, long after the
+ * archive was written correctly. Under PHP 8.1's default report mode that surfaces as an
+ * uncaught mysqli_sql_exception, which under the dispatcher takes the rest of the cron
+ * cycle with it.
+ *
+ * Called before every write that follows long file work. Returns a working handle, which
+ * may be a new one - callers must assign the result. $GLOBALS['mysqli'] is updated too,
+ * because logAudit(), appNotify() and the job scripts all reach for the global.
+ */
+function backupDbEnsure($mysqli)
+{
+ try {
+ if (@mysqli_query($mysqli, "SELECT 1")) {
+ return $mysqli;
+ }
+ } catch (Throwable $e) {
+ // Connection is gone - fall through and rebuild it
+ }
+
+ global $dbhost, $dbusername, $dbpassword, $database;
+
+ try {
+ $fresh = @mysqli_connect($dbhost, $dbusername, $dbpassword, $database);
+ } catch (Throwable $e) {
+ $fresh = false;
+ }
+
+ if ($fresh instanceof mysqli) {
+ backupDbHoldOpen($fresh);
+ $GLOBALS['mysqli'] = $fresh;
+ return $fresh;
+ }
+
+ // Nothing more to be done here. The caller's query will throw and be reported as the
+ // job failing, which is the correct outcome - better than pretending it succeeded.
+ return $mysqli;
+}
+
+/**
+ * Ask the server not to hang up during the quiet stretches. Best effort: a host may cap
+ * or refuse this, which is why backupDbEnsure() still exists.
+ */
+function backupDbHoldOpen($mysqli): void
+{
+ try {
+ @mysqli_query($mysqli, "SET SESSION wait_timeout = 28800");
+ } catch (Throwable $e) {
+ // Not permitted here - the reconnect path covers it
+ }
+}
+
+/**
+ * Where finished archives are kept. Overridable with $config_backup_path in config.php
+ * for anyone who would rather keep them off the web root entirely - which is the better
+ * place for them, and what the docs recommend.
+ */
+function backupStorageDir(): string
+{
+ global $config_backup_path;
+
+ if (!empty($config_backup_path)) {
+ $dir = rtrim($config_backup_path, '/\\');
+ } else {
+ $dir = backupAppRoot() . "/uploads/backups";
+ }
+
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0750, true);
+ }
+
+ backupHardenStorageDir($dir);
+
+ return $dir;
+}
+
+/**
+ * Re-assert the guards on a directory that must never be served.
+ *
+ * The .htaccess only covers Apache. It is deliberately a deny-all rather than the
+ * "turn PHP off" rule used elsewhere in uploads/, because nothing in here should ever
+ * be reachable over HTTP by any means. nginx installs get no protection from this file
+ * at all, which is why the archives are encrypted and their names carry a random token.
+ */
+function backupHardenStorageDir(string $dir): void
+{
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $htaccess = $dir . "/.htaccess";
+ if (!file_exists($htaccess)) {
+ @file_put_contents($htaccess, "Require all denied\nOptions -ExecCGI -Indexes\nphp_flag engine off\n");
+ }
+
+ $index = $dir . "/index.php";
+ if (!file_exists($index)) {
+ @file_put_contents($index, "\n Require all denied\n\n";
+
+ // Overwrite unconditionally - an archive is allowed to carry a .htaccess, it is not
+ // allowed to decide what ours says.
+ @file_put_contents($htaccess, $wanted);
+
+ $index = $uploads . "/index.php";
+ if (!file_exists($index)) {
+ @file_put_contents($index, "");
+ }
+}
+
+/**
+ * itflow_20260731-184500_full_<32 random chars>.zip
+ *
+ * The token is an unguessable path component, not a key. It buys nothing on its own -
+ * the encryption is what protects the contents - but it stops a directory guess on an
+ * install whose web server serves the folder anyway.
+ */
+function backupBuildFileName(string $type, string $token, ?int $timestamp = null): string
+{
+ $timestamp = $timestamp ?? time();
+
+ return "itflow_" . date('Ymd-His', $timestamp) . "_" . $type . "_" . $token . ".zip";
+}
+
+/**
+ * Branch and commit read straight out of .git - CONTRIBUTING rule 6 rules out shelling
+ * out to git, and the old backup handler was one of the last places still doing it.
+ */
+function backupGitInfo(): array
+{
+ $info = ['branch' => 'N/A', 'commit' => 'N/A'];
+
+ $git_dir = backupAppRoot() . "/.git";
+ if (!is_dir($git_dir)) {
+ return $info;
+ }
+
+ $head = @file_get_contents($git_dir . "/HEAD");
+ if ($head === false) {
+ return $info;
+ }
+
+ $head = trim($head);
+
+ if (str_starts_with($head, 'ref:')) {
+ $ref = trim(substr($head, 4));
+ $info['branch'] = basename($ref);
+
+ $ref_file = $git_dir . "/" . $ref;
+ if (is_file($ref_file)) {
+ $info['commit'] = trim(@file_get_contents($ref_file) ?: 'N/A');
+ } else {
+ // Packed refs - the loose file is gone once git gc has run
+ $packed = @file_get_contents($git_dir . "/packed-refs");
+ if ($packed !== false) {
+ foreach (explode("\n", $packed) as $line) {
+ if (str_ends_with(trim($line), " " . $ref)) {
+ $info['commit'] = strtok(trim($line), ' ');
+ break;
+ }
+ }
+ }
+ }
+ } else {
+ // Detached head
+ $info['commit'] = $head;
+ }
+
+ return $info;
+}
+
+/**
+ * Stream a SQL dump of schema and data into $sql_file.
+ *
+ * Every value goes through real_escape_string, which turns newlines into \n, so no
+ * statement in the output ever contains a raw newline inside a quoted value. That is what
+ * lets the importer split statements by accumulating lines until one ends in a semicolon.
+ * Anything that changes the escaping here has to change the importer too.
+ */
+function backupDumpDatabase(mysqli $mysqli, string $sql_file, ?string &$error = null): bool
+{
+ try {
+ return backupDumpDatabaseInner($mysqli, $sql_file, $error);
+ } catch (Throwable $e) {
+ $error = $error ?: $e->getMessage();
+ return false;
+ }
+}
+
+function backupDumpDatabaseInner(mysqli $mysqli, string $sql_file, ?string &$error = null): bool
+{
+ $fh = fopen($sql_file, 'wb');
+ if (!$fh) {
+ $error = "Cannot open dump file for writing";
+ return false;
+ }
+
+ $write = function ($line) use ($fh) {
+ fwrite($fh, $line);
+ fwrite($fh, "\n");
+ };
+
+ $write("-- ITFlow database backup");
+ $write("-- Generated " . date('Y-m-d H:i:s'));
+ $write("SET NAMES 'utf8mb4';");
+ $write("SET FOREIGN_KEY_CHECKS = 0;");
+ $write("SET UNIQUE_CHECKS = 0;");
+ $write("");
+
+ $tables = [];
+ $views = [];
+
+ $res = mysqli_query($mysqli, "SHOW FULL TABLES");
+ if (!$res) {
+ fclose($fh);
+ $error = "Could not list tables: " . mysqli_error($mysqli);
+ return false;
+ }
+ while ($row = mysqli_fetch_array($res, MYSQLI_NUM)) {
+ if (strtoupper($row[1] ?? '') === 'VIEW') {
+ $views[] = $row[0];
+ } else {
+ $tables[] = $row[0];
+ }
+ }
+ mysqli_free_result($res);
+
+ if (empty($tables)) {
+ fclose($fh);
+ $error = "Database contains no tables - refusing to write an empty backup";
+ return false;
+ }
+
+ foreach ($tables as $table) {
+ $create_res = mysqli_query($mysqli, "SHOW CREATE TABLE `$table`");
+ if (!$create_res) {
+ fclose($fh);
+ $error = "Could not read structure of `$table`: " . mysqli_error($mysqli);
+ return false;
+ }
+ $create_row = mysqli_fetch_assoc($create_res);
+ $create_sql = array_values($create_row)[1] ?? '';
+ mysqli_free_result($create_res);
+
+ $write("-- Table `$table`");
+ $write("DROP TABLE IF EXISTS `$table`;");
+ $write($create_sql . ";");
+ $write("");
+
+ // Unbuffered so a large table does not have to fit in memory. Nothing else may
+ // query on this connection until the result is closed.
+ $data_res = mysqli_query($mysqli, "SELECT * FROM `$table`", MYSQLI_USE_RESULT);
+ if ($data_res) {
+ while ($row = mysqli_fetch_assoc($data_res)) {
+ $cols = [];
+ $vals = [];
+ foreach ($row as $col => $val) {
+ $cols[] = '`' . $col . '`';
+ $vals[] = is_null($val) ? "NULL" : "'" . mysqli_real_escape_string($mysqli, $val) . "'";
+ }
+ $write("INSERT INTO `$table` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");");
+ }
+ mysqli_free_result($data_res);
+ $write("");
+ }
+ }
+
+ foreach ($views as $view) {
+ $view_res = mysqli_query($mysqli, "SHOW CREATE VIEW `$view`");
+ if ($view_res) {
+ $row = mysqli_fetch_assoc($view_res);
+ $create_view = $row['Create View'] ?? '';
+ mysqli_free_result($view_res);
+
+ $write("-- View `$view`");
+ $write("DROP VIEW IF EXISTS `$view`;");
+ $write(rtrim($create_view, ';') . ";");
+ $write("");
+ }
+ }
+
+ $write("SET FOREIGN_KEY_CHECKS = 1;");
+ $write("SET UNIQUE_CHECKS = 1;");
+
+ fclose($fh);
+
+ return true;
+}
+
+/**
+ * Zip the uploads folder, skipping symlinks and the backup storage directory itself -
+ * without that exclusion every full backup would contain all previous full backups.
+ */
+function backupZipUploads(string $folder, string $zip_path, ?string &$error = null): bool
+{
+ $zip = new ZipArchive();
+ if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ $error = "Could not create the uploads archive";
+ return false;
+ }
+
+ $folder_real = realpath($folder);
+ if (!$folder_real || !is_dir($folder_real)) {
+ // Nothing to add - a fresh install may not have written to uploads yet
+ $zip->close();
+ return true;
+ }
+
+ $exclude = realpath(backupStorageDir());
+
+ $files = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($folder_real, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::LEAVES_ONLY
+ );
+
+ foreach ($files as $file) {
+ if ($file->isDir() || $file->isLink()) {
+ continue;
+ }
+
+ $file_path = $file->getRealPath();
+ if ($file_path === false) {
+ continue;
+ }
+
+ // Stay inside the uploads boundary
+ if (strpos($file_path, $folder_real . DIRECTORY_SEPARATOR) !== 0) {
+ continue;
+ }
+
+ // Never nest backups inside a backup
+ if ($exclude && strpos($file_path, $exclude . DIRECTORY_SEPARATOR) === 0) {
+ continue;
+ }
+
+ $zip->addFile($file_path, substr($file_path, strlen($folder_real) + 1));
+ }
+
+ $zip->close();
+
+ return true;
+}
+
+/**
+ * Put the finished parts into an AES-256 encrypted zip.
+ *
+ * ZipArchive encrypts entry data but not entry names, which is fine - the names are
+ * db.sql, uploads.zip and version.txt on every archive we make.
+ */
+function backupSealArchive(array $entries, string $zip_path, string $key, ?string &$error = null): bool
+{
+ if ($key === '') {
+ $error = "No backup encryption key available";
+ return false;
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open($zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ $error = "Could not create the backup archive";
+ return false;
+ }
+
+ $zip->setPassword($key);
+
+ foreach ($entries as $name => $path) {
+ if (!$zip->addFile($path, $name)) {
+ $zip->close();
+ $error = "Could not add $name to the archive";
+ return false;
+ }
+ if (!$zip->setEncryptionName($name, ZipArchive::EM_AES_256)) {
+ $zip->close();
+ $error = "This server's zip library cannot produce AES-256 encrypted archives (libzip 1.2 or newer is required)";
+ return false;
+ }
+ }
+
+ if (!$zip->close()) {
+ $error = "Could not finish writing the archive";
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Put a backup on the queue for the dispatcher to build.
+ *
+ * The web tier never generates an archive inline. A dump of a real install takes longer
+ * than a web request is allowed to live on most hosts - PHP-FPM's request_terminate_timeout
+ * and the front end's read timeout both cut it off, and neither is affected by
+ * set_time_limit() - so the button records the intent and cron/backup.php does the work
+ * within the minute. Same shape as the Run Now button on Settings > Cron.
+ */
+function backupQueue(mysqli $mysqli, string $type, string $created_by, ?string &$error = null): int
+{
+ if (!in_array($type, backupUnattendedTypes(), true)) {
+ $error = "That backup type cannot be queued";
+ return 0;
+ }
+
+ if (backupEncryptionKey() === '') {
+ $error = "No backup encryption key is set and config.php is not writable - cannot make an encrypted backup";
+ return 0;
+ }
+
+ $type_esc = escapeSql($type);
+ $created_by_esc = escapeSql($created_by);
+
+ mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '', backup_status = 'Pending', backup_source = 'Manual', backup_created_by = '$created_by_esc'");
+
+ return intval(mysqli_insert_id($mysqli));
+}
+
+/**
+ * Build every queued backup. Called by cron/backup.php.
+ */
+function backupRunQueued(mysqli $mysqli): int
+{
+ $built = 0;
+
+ $sql = mysqli_query($mysqli, "SELECT backup_id, backup_type, backup_created_by FROM backups WHERE backup_status = 'Pending' ORDER BY backup_created_at ASC");
+ if (!$sql) {
+ return 0;
+ }
+
+ $queued = [];
+ while ($row = mysqli_fetch_assoc($sql)) {
+ $queued[] = $row;
+ }
+
+ foreach ($queued as $row) {
+ $backup_id = intval($row['backup_id']);
+
+ // Claim before building so a second dispatcher cannot pick up the same row
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Running' WHERE backup_id = $backup_id AND backup_status = 'Pending'");
+ if (mysqli_affected_rows($mysqli) !== 1) {
+ continue;
+ }
+
+ $error = null;
+ $built_ok = backupCreate($mysqli, $row['backup_type'], $row['backup_created_by'] ?: 'Cron', 'Manual', $error, [], $backup_id);
+
+ // backupCreate may have rebuilt the connection under us
+ $mysqli = backupDbEnsure($GLOBALS['mysqli'] ?? $mysqli);
+
+ if ($built_ok) {
+ $built++;
+ appNotify("Backup", backupTypeLabel($row['backup_type']) . " is ready to download", "/admin/backup.php");
+ logAudit("Backup", "Create", backupTypeLabel($row['backup_type']) . " completed");
+ } else {
+ appNotify("Backup", backupTypeLabel($row['backup_type']) . " failed: " . $error, "/admin/backup.php");
+ logAudit("Backup", "Create", backupTypeLabel($row['backup_type']) . " failed: " . $error);
+ }
+ }
+
+ return $built;
+}
+
+/**
+ * Create a backup and record it.
+ *
+ * $extra['master_key'] is required for the master_key type and ignored for the others.
+ * $backup_id updates an existing row (a queued one) rather than inserting a new one.
+ * Returns the backup_id, or 0 on failure with $error set.
+ */
+function backupCreate(mysqli $mysqli, string $type, string $created_by, string $source, ?string &$error = null, array $extra = [], int $backup_id = 0): int
+{
+ if (!in_array($type, backupAllTypes(), true)) {
+ $error = "Unknown backup type";
+ return 0;
+ }
+
+ if ($type === BACKUP_TYPE_MASTER_KEY && empty($extra['master_key'])) {
+ $error = "The master key backup needs the master key and can only be made from the web interface";
+ return 0;
+ }
+
+ $key = backupEncryptionKey();
+ if ($key === '') {
+ $error = "No backup encryption key is set and config.php is not writable - cannot make an encrypted backup";
+ return 0;
+ }
+
+ @set_time_limit(0);
+ backupDbHoldOpen($mysqli);
+
+ $token = randomString(32);
+ $file_name = backupBuildFileName($type, $token);
+ $storage_dir = backupStorageDir();
+ $final_path = $storage_dir . "/" . $file_name;
+
+ $created_by_esc = escapeSql($created_by);
+ $type_esc = escapeSql($type);
+ $source_esc = escapeSql($source);
+ $file_name_esc = escapeSql($file_name);
+
+ if ($backup_id > 0) {
+ mysqli_query($mysqli, "UPDATE backups SET backup_file_name = '$file_name_esc', backup_status = 'Running' WHERE backup_id = $backup_id");
+ } else {
+ mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '$file_name_esc', backup_status = 'Running', backup_source = '$source_esc', backup_created_by = '$created_by_esc'");
+ $backup_id = intval(mysqli_insert_id($mysqli));
+ }
+
+ if (!is_dir($storage_dir) || !is_writable($storage_dir)) {
+ $error = "Backup directory is not writable: $storage_dir";
+ $error_esc = escapeSql($error);
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = '$error_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");
+ return 0;
+ }
+
+ // Temp files live outside the web root and are removed however this function exits
+ $temp_files = [];
+ $cleanup = function () use (&$temp_files) {
+ foreach ($temp_files as $file) {
+ if (is_file($file)) {
+ @unlink($file);
+ }
+ }
+ };
+
+ $fail = function ($message) use (&$mysqli, $backup_id, $cleanup, $final_path, &$error) {
+ $cleanup();
+ $mysqli = backupDbEnsure($mysqli);
+ if (is_file($final_path)) {
+ @unlink($final_path);
+ }
+ $error = $message;
+ $message_esc = escapeSql($message);
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = '$message_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");
+ return 0;
+ };
+
+ $entries = [];
+ $sub_error = null;
+
+ // --- db.sql ---
+ if ($type === BACKUP_TYPE_FULL || $type === BACKUP_TYPE_DATABASE) {
+ $sql_file = tempnam(sys_get_temp_dir(), "itflow_sql_");
+ $temp_files[] = $sql_file;
+ @chmod($sql_file, 0600);
+
+ if (!backupDumpDatabase($mysqli, $sql_file, $sub_error)) {
+ return $fail($sub_error ?? "Database dump failed");
+ }
+ $entries['db.sql'] = $sql_file;
+ }
+
+ // --- uploads.zip ---
+ if ($type === BACKUP_TYPE_FULL) {
+ $uploads_zip = tempnam(sys_get_temp_dir(), "itflow_uploads_");
+ $temp_files[] = $uploads_zip;
+ @chmod($uploads_zip, 0600);
+
+ if (!backupZipUploads(backupAppRoot() . "/uploads", $uploads_zip, $sub_error)) {
+ return $fail($sub_error ?? "Could not archive the uploads directory");
+ }
+ $entries['uploads.zip'] = $uploads_zip;
+ }
+
+ // --- master_key.txt ---
+ if ($type === BACKUP_TYPE_MASTER_KEY) {
+ $key_file = tempnam(sys_get_temp_dir(), "itflow_mk_");
+ $temp_files[] = $key_file;
+ @chmod($key_file, 0600);
+
+ $key_content = "ITFlow master encryption key\n";
+ $key_content .= "============================\n\n";
+ $key_content .= $extra['master_key'] . "\n\n";
+ $key_content .= "This key decrypts every credential stored in this ITFlow install.\n";
+ $key_content .= "It is only needed if every user password is lost - a normal restore\n";
+ $key_content .= "recovers the vault from the database on its own.\n";
+ $key_content .= "Exported " . date('Y-m-d H:i:s') . " by " . $created_by . "\n";
+
+ file_put_contents($key_file, $key_content);
+ $entries['master_key.txt'] = $key_file;
+ }
+
+ // --- version.txt ---
+ $git = backupGitInfo();
+ $version_file = tempnam(sys_get_temp_dir(), "itflow_ver_");
+ $temp_files[] = $version_file;
+ @chmod($version_file, 0600);
+
+ $meta = "ITFlow Backup Metadata\n";
+ $meta .= "-----------------------------\n";
+ $meta .= "Backup Type: " . $type . "\n";
+ $meta .= "Generated: " . date('Y-m-d H:i:s') . "\n";
+ $meta .= "Generated By: " . $created_by . "\n";
+ $meta .= "Source: " . $source . "\n";
+ $meta .= "Host: " . gethostname() . "\n";
+ $meta .= "Git Branch: " . $git['branch'] . "\n";
+ $meta .= "Git Commit: " . $git['commit'] . "\n";
+ $meta .= "ITFlow Version: " . (defined('APP_VERSION') ? APP_VERSION : 'Unknown') . "\n";
+ $meta .= "Database Version: " . backupCurrentDatabaseVersion($mysqli) . "\n";
+ $meta .= "Checksums (SHA256):\n";
+ foreach ($entries as $name => $path) {
+ $meta .= " " . $name . ": " . (hash_file('sha256', $path) ?: 'N/A') . "\n";
+ }
+
+ file_put_contents($version_file, $meta);
+ $entries['version.txt'] = $version_file;
+
+ // --- seal ---
+ if (!backupSealArchive($entries, $final_path, $key, $sub_error)) {
+ return $fail($sub_error ?? "Could not encrypt the archive");
+ }
+
+ @chmod($final_path, 0600);
+ $cleanup();
+
+ $size = filesize($final_path) ?: 0;
+ $sha = hash_file('sha256', $final_path) ?: '';
+ $sha_esc = escapeSql($sha);
+
+ // The archive is written by this point. Everything below is bookkeeping, and it runs
+ // after minutes of dumping, zipping and encrypting with the connection idle.
+ $mysqli = backupDbEnsure($mysqli);
+
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Complete', backup_size = $size, backup_sha256 = '$sha_esc', backup_completed_at = NOW() WHERE backup_id = $backup_id");
+
+ return $backup_id;
+}
+
+/**
+ * The database version the install is currently stamped at, read from settings so it is
+ * correct under cron as well as in a request.
+ */
+function backupCurrentDatabaseVersion(mysqli $mysqli): string
+{
+ $res = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
+ if ($res && $row = mysqli_fetch_assoc($res)) {
+ return $row['config_current_database_version'] ?? 'Unknown';
+ }
+ return 'Unknown';
+}
+
+/**
+ * Remove a backup, file and row together. Missing files are not an error - the point is
+ * that neither half is left behind.
+ */
+function backupDeleteById(mysqli $mysqli, int $backup_id): bool
+{
+ $backup_id = intval($backup_id);
+
+ $res = mysqli_query($mysqli, "SELECT backup_file_name FROM backups WHERE backup_id = $backup_id");
+ if (!$res || mysqli_num_rows($res) !== 1) {
+ return false;
+ }
+ $row = mysqli_fetch_assoc($res);
+
+ $path = backupResolvePath($row['backup_file_name']);
+ if ($path !== false && is_file($path)) {
+ @unlink($path);
+ }
+
+ mysqli_query($mysqli, "DELETE FROM backups WHERE backup_id = $backup_id");
+
+ return true;
+}
+
+/**
+ * Turn a stored file name into an absolute path, refusing anything that tries to leave
+ * the backup directory. The name comes from our own row, but this is the only function
+ * that turns a database value into a filesystem path so the check belongs here.
+ */
+function backupResolvePath(string $file_name)
+{
+ $file_name = basename($file_name);
+
+ if ($file_name === '' || !preg_match('/^itflow_[0-9]{8}-[0-9]{6}_[a-z_]+_[A-Za-z0-9\-_]{32}\.zip$/', $file_name)) {
+ return false;
+ }
+
+ $dir = realpath(backupStorageDir());
+ if ($dir === false) {
+ return false;
+ }
+
+ return $dir . "/" . $file_name;
+}
+
+/**
+ * Delete backups past the retention settings, and reconcile both kinds of orphan.
+ *
+ * Safe to run more than once in a day - everything here is a delete. Never removes the
+ * most recent complete backup whatever the settings say, so a badly set retention cannot
+ * leave an install with nothing.
+ */
+function backupRunRetention(mysqli $mysqli): array
+{
+ $result = ['deleted' => 0, 'orphan_files' => 0, 'orphan_rows' => 0, 'recovered' => 0];
+
+ $res = mysqli_query($mysqli, "SELECT config_backup_retention_days, config_backup_retention_count FROM settings WHERE company_id = 1");
+ $settings = $res ? mysqli_fetch_assoc($res) : [];
+
+ $days = intval($settings['config_backup_retention_days'] ?? 30);
+ $count = intval($settings['config_backup_retention_count'] ?? 5);
+
+ // A run whose connection died before it could mark itself complete leaves a Running row
+ // for an archive that is sitting there perfectly good. Anything still Running after six
+ // hours is finished one way or the other, decided by whether the file exists.
+ $stale = mysqli_query($mysqli, "SELECT backup_id, backup_file_name FROM backups WHERE backup_status IN ('Running','Pending') AND backup_created_at < NOW() - INTERVAL 6 HOUR");
+ if ($stale) {
+ while ($row = mysqli_fetch_assoc($stale)) {
+ $stale_id = intval($row['backup_id']);
+ $stale_path = $row['backup_file_name'] === '' ? false : backupResolvePath($row['backup_file_name']);
+
+ if ($stale_path !== false && is_file($stale_path)) {
+ $stale_size = filesize($stale_path) ?: 0;
+ $stale_sha = escapeSql(hash_file('sha256', $stale_path) ?: '');
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Complete', backup_size = $stale_size, backup_sha256 = '$stale_sha', backup_completed_at = backup_created_at WHERE backup_id = $stale_id");
+ $result['recovered'] = ($result['recovered'] ?? 0) + 1;
+ } else {
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Failed', backup_error = 'Run did not finish', backup_completed_at = NOW() WHERE backup_id = $stale_id");
+ }
+ }
+ }
+
+
+ // Keep the newest $count complete backups regardless of age
+ $keep = [];
+ $keep_res = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Complete' ORDER BY backup_created_at DESC LIMIT " . max(1, $count));
+ if ($keep_res) {
+ while ($row = mysqli_fetch_assoc($keep_res)) {
+ $keep[] = intval($row['backup_id']);
+ }
+ }
+
+ $keep_clause = empty($keep) ? "" : " AND backup_id NOT IN (" . implode(",", $keep) . ")";
+
+ // Age-based removal
+ if ($days > 0) {
+ $old = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_created_at < CURDATE() - INTERVAL $days DAY $keep_clause");
+ if ($old) {
+ while ($row = mysqli_fetch_assoc($old)) {
+ if (backupDeleteById($mysqli, intval($row['backup_id']))) {
+ $result['deleted']++;
+ }
+ }
+ }
+ }
+
+ // Count-based removal
+ if ($count > 0 && !empty($keep)) {
+ $surplus = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Complete' $keep_clause");
+ if ($surplus) {
+ while ($row = mysqli_fetch_assoc($surplus)) {
+ if (backupDeleteById($mysqli, intval($row['backup_id']))) {
+ $result['deleted']++;
+ }
+ }
+ }
+ }
+
+ // Failed rows never had a usable file
+ $failed = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Failed' AND backup_created_at < CURDATE() - INTERVAL 7 DAY");
+ if ($failed) {
+ while ($row = mysqli_fetch_assoc($failed)) {
+ if (backupDeleteById($mysqli, intval($row['backup_id']))) {
+ $result['deleted']++;
+ }
+ }
+ }
+
+ // Orphans: rows whose file is gone, and files with no row
+ $known = [];
+ $rows = mysqli_query($mysqli, "SELECT backup_id, backup_file_name, backup_status FROM backups");
+ if ($rows) {
+ while ($row = mysqli_fetch_assoc($rows)) {
+ $known[$row['backup_file_name']] = true;
+ if ($row['backup_status'] !== 'Complete') {
+ continue;
+ }
+ $path = backupResolvePath($row['backup_file_name']);
+ if ($path === false || !is_file($path)) {
+ mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Missing' WHERE backup_id = " . intval($row['backup_id']));
+ $result['orphan_rows']++;
+ }
+ }
+ }
+
+ // Archives on disk that the table does not know about are ADOPTED, not deleted.
+ // A restore brings back the backups table as it was when the backup was taken, so
+ // every archive made since then looks unknown - deleting them would quietly destroy
+ // good backups, including the one that was just restored from. Once adopted they age
+ // out under the normal rules. The strict name check in backupResolvePath is what stops
+ // an unrelated file being adopted.
+ $dir = backupStorageDir();
+ foreach (glob($dir . "/itflow_*.zip") ?: [] as $file) {
+ $name = basename($file);
+
+ if (isset($known[$name])) {
+ continue;
+ }
+
+ if (backupResolvePath($name) === false) {
+ continue;
+ }
+
+ $type = 'full';
+ $created = date('Y-m-d H:i:s', filemtime($file) ?: time());
+ if (preg_match('/^itflow_([0-9]{8})-([0-9]{6})_([a-z_]+)_[A-Za-z0-9\-_]{32}\.zip$/', $name, $m)) {
+ $stamp = strtotime($m[1] . ' ' . $m[2]);
+ if ($stamp !== false) {
+ $created = date('Y-m-d H:i:s', $stamp);
+ }
+ if (in_array($m[3], backupAllTypes(), true)) {
+ $type = $m[3];
+ }
+ }
+
+ $name_esc = escapeSql($name);
+ $type_esc = escapeSql($type);
+ $size = filesize($file) ?: 0;
+
+ mysqli_query($mysqli, "INSERT INTO backups SET backup_type = '$type_esc', backup_file_name = '$name_esc', backup_size = $size, backup_status = 'Complete', backup_source = 'Adopted', backup_created_at = '$created', backup_completed_at = '$created'");
+
+ $result['orphan_files']++;
+ }
+
+ return $result;
+}
+
+/*
+ * ###############################################################################################################
+ * RESTORE
+ * ###############################################################################################################
+ */
+
+/**
+ * Open an encrypted archive and read its version.txt without touching the database.
+ *
+ * This is the pre-flight: it proves the key is right and the archive is one of ours
+ * before anything destructive happens.
+ */
+function backupInspectArchive(string $zip_path, string $key, ?string &$error = null)
+{
+ if (!is_file($zip_path) || !is_readable($zip_path)) {
+ $error = "Backup file not found or not readable";
+ return false;
+ }
+
+ $zip = new ZipArchive();
+ if ($zip->open($zip_path) !== true) {
+ $error = "This file is not a readable zip archive";
+ return false;
+ }
+
+ $names = [];
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = $zip->getNameIndex($i);
+ if ($name !== false) {
+ $names[] = $name;
+ }
+ }
+
+ if (!in_array('version.txt', $names, true)) {
+ $zip->close();
+ $error = "This does not look like an ITFlow backup - version.txt is missing";
+ return false;
+ }
+
+ $zip->setPassword($key);
+ $meta_raw = $zip->getFromName('version.txt');
+
+ if ($meta_raw === false) {
+ $status = $zip->getStatusString();
+ $zip->close();
+ if (stripos($status, 'password') !== false) {
+ $error = "Wrong backup encryption key for this archive";
+ } else {
+ $error = "Could not read the archive: $status";
+ }
+ return false;
+ }
+
+ $zip->close();
+
+ $meta = ['raw' => $meta_raw, 'entries' => $names, 'type' => BACKUP_TYPE_FULL, 'database_version' => 'Unknown', 'app_version' => 'Unknown', 'generated' => 'Unknown'];
+
+ foreach (explode("\n", $meta_raw) as $line) {
+ if (preg_match('/^Backup Type:\s*(.+)$/', $line, $m)) {
+ $meta['type'] = trim($m[1]);
+ } elseif (preg_match('/^Database Version:\s*(.+)$/', $line, $m)) {
+ $meta['database_version'] = trim($m[1]);
+ } elseif (preg_match('/^ITFlow Version:\s*(.+)$/', $line, $m)) {
+ $meta['app_version'] = trim($m[1]);
+ } elseif (preg_match('/^Generated:\s*(.+)$/', $line, $m)) {
+ $meta['generated'] = trim($m[1]);
+ }
+ }
+
+ if ($meta['type'] === BACKUP_TYPE_MASTER_KEY) {
+ $error = "This is a master key export, not a restorable backup";
+ return false;
+ }
+
+ if (!in_array('db.sql', $names, true)) {
+ $error = "This archive contains no database dump";
+ return false;
+ }
+
+ return $meta;
+}
+
+/**
+ * Run a SQL file into the database, one statement at a time.
+ *
+ * Statements are accumulated until a line ends with the delimiter. That is safe for our
+ * own dumps because backupDumpDatabase escapes every value, so no raw newline can appear
+ * inside a quoted string - see the note there.
+ */
+function backupImportSql(mysqli $mysqli, $handle, ?string &$error = null): bool
+{
+ $delimiter = ';';
+ $statement = '';
+ $line_number = 0;
+
+ while (($line = fgets($handle)) !== false) {
+ $line_number++;
+ $trimmed = trim($line);
+
+ if ($trimmed === '' || str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
+ continue;
+ }
+
+ if (preg_match('/^DELIMITER\s+(.+)$/i', $trimmed, $m)) {
+ $delimiter = trim($m[1]);
+ continue;
+ }
+
+ $statement .= $line;
+
+ if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) {
+ $sql = substr(rtrim($statement), 0, -strlen($delimiter));
+ $statement = '';
+
+ if (trim($sql) === '') {
+ continue;
+ }
+
+ // mysqli throws on error under PHP 8.1's default report mode, so a bad
+ // statement has to be caught here rather than tested for. Letting it escape
+ // would abort the restore with the tables already dropped and the rollback
+ // below never reached - which is the exact failure this function exists to
+ // survive.
+ try {
+ if (!mysqli_query($mysqli, $sql)) {
+ $error = "SQL error near line $line_number: " . mysqli_error($mysqli);
+ return false;
+ }
+ } catch (Throwable $e) {
+ $error = "SQL error near line $line_number: " . $e->getMessage();
+ return false;
+ }
+ }
+ }
+
+ if (trim($statement) !== '') {
+ $error = "The dump ended in the middle of a statement - the file is truncated";
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Restore an encrypted archive over this install.
+ *
+ * Order matters. Everything that can fail without consequence happens first: the key is
+ * checked, the archive is unpacked to a temp directory, and the current database is dumped
+ * to a rollback file. Only then are the existing tables dropped. If the import fails after
+ * that point the rollback dump is put back, so a bad archive cannot leave an install with
+ * no database at all.
+ *
+ * $progress is called with a short status string so the CLI can print it and the web path
+ * can ignore it.
+ */
+function backupRestoreArchive(mysqli $mysqli, string $zip_path, string $key, ?string &$error = null, ?callable $progress = null): bool
+{
+ $say = function ($message) use ($progress) {
+ if ($progress) {
+ $progress($message);
+ }
+ };
+
+ @set_time_limit(0);
+
+ $meta = backupInspectArchive($zip_path, $key, $error);
+ if ($meta === false) {
+ return false;
+ }
+
+ $say("Archive verified (" . backupTypeLabel($meta['type']) . " taken " . $meta['generated'] . ")");
+
+ $temp_dir = sys_get_temp_dir() . "/itflow_restore_" . bin2hex(random_bytes(8));
+ if (!mkdir($temp_dir, 0700, true)) {
+ $error = "Could not create a temporary directory for the restore";
+ return false;
+ }
+
+ $cleanup = function () use ($temp_dir) {
+ backupDeleteDirectory($temp_dir);
+ };
+
+ $zip = new ZipArchive();
+ if ($zip->open($zip_path) !== true) {
+ $cleanup();
+ $error = "Could not reopen the archive";
+ return false;
+ }
+
+ // Zip-slip guard on the outer archive
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = $zip->getNameIndex($i);
+ if ($name === false) {
+ continue;
+ }
+ if (!backupSafeEntryName($name)) {
+ $zip->close();
+ $cleanup();
+ $error = "The archive contains an unsafe path: $name";
+ return false;
+ }
+ }
+
+ $zip->setPassword($key);
+ if (!$zip->extractTo($temp_dir)) {
+ $status = $zip->getStatusString();
+ $zip->close();
+ $cleanup();
+ $error = "Could not extract the archive: $status";
+ return false;
+ }
+ $zip->close();
+
+ // Unpacking a multi-gigabyte archive is minutes of idle connection, and everything
+ // below is database work.
+ $mysqli = backupDbEnsure($mysqli);
+ backupDbHoldOpen($mysqli);
+
+ $sql_path = $temp_dir . "/db.sql";
+ if (!is_file($sql_path)) {
+ $cleanup();
+ $error = "The archive did not contain db.sql";
+ return false;
+ }
+
+ $say("Dumping the current database so it can be put back if this fails");
+
+ $rollback = tempnam(sys_get_temp_dir(), "itflow_rollback_");
+ @chmod($rollback, 0600);
+ $rollback_error = null;
+ $have_rollback = backupDumpDatabase($mysqli, $rollback, $rollback_error);
+
+ if (!$have_rollback) {
+ // An empty database is the normal case on a fresh install, and there is nothing
+ // to roll back to. Any other failure means we cannot guarantee recovery.
+ if (stripos((string)$rollback_error, 'no tables') === false) {
+ $cleanup();
+ @unlink($rollback);
+ $error = "Could not dump the current database before restoring: $rollback_error";
+ return false;
+ }
+ }
+
+ $say("Replacing the database");
+
+ backupDropAllTables($mysqli);
+
+ $fh = fopen($sql_path, 'r');
+ if (!$fh) {
+ $cleanup();
+ $error = "Could not open db.sql from the archive";
+ return false;
+ }
+
+ $import_error = null;
+ $imported = backupImportSql($mysqli, $fh, $import_error);
+ fclose($fh);
+
+ if (!$imported) {
+ $error = "Restore failed: $import_error";
+
+ if ($have_rollback) {
+ $say("Import failed - putting the previous database back");
+ backupDropAllTables($mysqli);
+
+ $rb = fopen($rollback, 'r');
+ if ($rb) {
+ $rb_error = null;
+ if (backupImportSql($mysqli, $rb, $rb_error)) {
+ $error .= " - the previous database has been restored, nothing was lost";
+ } else {
+ $error .= " - AND the rollback also failed ($rb_error). The dump of your previous database is at $rollback - do not delete it";
+ }
+ fclose($rb);
+ }
+ }
+
+ $cleanup();
+ if (strpos($error, $rollback) === false) {
+ @unlink($rollback);
+ }
+ return false;
+ }
+
+ @unlink($rollback);
+
+ // --- uploads ---
+ $uploads_zip = $temp_dir . "/uploads.zip";
+ if (is_file($uploads_zip)) {
+ $say("Restoring uploads");
+
+ $uploads_dir = backupAppRoot() . "/uploads";
+
+ $uz = new ZipArchive();
+ if ($uz->open($uploads_zip) !== true) {
+ $cleanup();
+ backupAssertUploadsGuards();
+ $error = "The database was restored but uploads.zip could not be opened";
+ return false;
+ }
+
+ for ($i = 0; $i < $uz->numFiles; $i++) {
+ $name = $uz->getNameIndex($i);
+ if ($name === false) {
+ continue;
+ }
+ if (!backupSafeEntryName($name)) {
+ $uz->close();
+ $cleanup();
+ backupAssertUploadsGuards();
+ $error = "The database was restored but uploads.zip contains an unsafe path: $name";
+ return false;
+ }
+ }
+
+ if (!is_dir($uploads_dir)) {
+ mkdir($uploads_dir, 0750, true);
+ } else {
+ // Clear uploads, but never the backup directory. It lives under uploads/ by
+ // default, and wiping it would destroy every other archive on the box -
+ // including the one being restored from, if it was copied in there.
+ backupEmptyDirectory($uploads_dir, [backupStorageDir()]);
+ }
+
+ $extracted = $uz->extractTo($uploads_dir);
+ $uz->close();
+
+ if (!$extracted) {
+ $cleanup();
+ backupAssertUploadsGuards();
+ $error = "The database was restored but the uploads could not be extracted";
+ return false;
+ }
+ }
+
+ // Whatever the archive carried, our own guards are what ends up on disk
+ backupAssertUploadsGuards();
+ backupHardenStorageDir(backupStorageDir());
+
+ $cleanup();
+
+ $say("Restore complete");
+
+ return true;
+}
+
+/**
+ * Drop every table in the current database. Used before an import and again before a
+ * rollback import, so it is written once.
+ */
+function backupDropAllTables(mysqli $mysqli): void
+{
+ try {
+ mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0");
+ $tables = mysqli_query($mysqli, "SHOW TABLES");
+ $names = [];
+ if ($tables) {
+ while ($row = mysqli_fetch_row($tables)) {
+ $names[] = $row[0];
+ }
+ }
+ foreach ($names as $name) {
+ mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $name . "`");
+ }
+ mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1");
+ } catch (Throwable $e) {
+ // Nothing useful to do here - the import that follows will report the real problem
+ }
+}
+
+/**
+ * Reject absolute paths, traversal and anything that resolves oddly on Windows.
+ */
+function backupSafeEntryName(string $name): bool
+{
+ if ($name === '') {
+ return false;
+ }
+ if (strpos($name, '..') !== false) {
+ return false;
+ }
+ if (preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
+ return false;
+ }
+ return true;
+}
+
+function backupEmptyDirectory(string $dir, array $preserve = []): void
+{
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $preserve = array_filter(array_map('realpath', $preserve));
+
+ $items = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($items as $item) {
+ $path = $item->getPathname();
+
+ $skip = false;
+ foreach ($preserve as $keep) {
+ if ($path === $keep || strpos($path, $keep . DIRECTORY_SEPARATOR) === 0) {
+ $skip = true;
+ break;
+ }
+ }
+ if ($skip) {
+ continue;
+ }
+
+ $item->isDir() ? @rmdir($path) : @unlink($path);
+ }
+}
+
+function backupDeleteDirectory(string $dir): void
+{
+ backupEmptyDirectory($dir);
+ @rmdir($dir);
+}
+
+/**
+ * The effective upload ceiling for the setup restore form, in bytes.
+ * The smaller of upload_max_filesize and post_max_size is what actually applies.
+ */
+function backupMaxUploadBytes(): int
+{
+ $upload = backupParseIniBytes(ini_get('upload_max_filesize'));
+ $post = backupParseIniBytes(ini_get('post_max_size'));
+
+ $limits = array_filter([$upload, $post], fn($v) => $v > 0);
+
+ return empty($limits) ? 0 : min($limits);
+}
+
+function backupParseIniBytes($value): int
+{
+ $value = trim((string)$value);
+ if ($value === '') {
+ return 0;
+ }
+
+ $unit = strtolower(substr($value, -1));
+ $number = (int)$value;
+
+ switch ($unit) {
+ case 'g':
+ return $number * 1024 * 1024 * 1024;
+ case 'm':
+ return $number * 1024 * 1024;
+ case 'k':
+ return $number * 1024;
+ }
+
+ return $number;
+}
+
+function backupFormatBytes($bytes): string
+{
+ $bytes = (float)$bytes;
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ $i = 0;
+ while ($bytes >= 1024 && $i < count($units) - 1) {
+ $bytes /= 1024;
+ $i++;
+ }
+ return round($bytes, $i === 0 ? 0 : 1) . ' ' . $units[$i];
+}
diff --git a/includes/cron_jobs.php b/includes/cron_jobs.php
index b1adf893..088e721f 100644
--- a/includes/cron_jobs.php
+++ b/includes/cron_jobs.php
@@ -23,6 +23,10 @@
* That shared use is why this sits here rather than in cron/includes/ with the lock, which
* only cron loads: the admin pages would otherwise be reaching into the cron directory.
*
+ * 'enabled' => 0 ships a job switched off - the row is seeded disabled and stays that way
+ * until somebody turns it on in Settings > Cron. Used for work an install should opt into
+ * rather than inherit from an upgrade, like the backup job filling a disk overnight.
+ *
* 'interval_safe' => false marks a job whose work repeats if the day repeats - nightly's
* late fees and overdue reminders fire again on a second run of the same day. Settings >
* Cron only offers the daily schedule for such a job, and the dispatcher refuses to run
@@ -73,6 +77,16 @@ function cronJobRegistry(): array
'daily_at' => '03:00',
'interval_safe' => false,
],
+ [
+ 'name' => 'backup',
+ 'label' => 'Backup',
+ 'script' => 'backup.php',
+ 'description' => 'Builds the scheduled backup and anything queued from Settings > Backup. Off by default.',
+ 'schedule' => 'Daily',
+ 'daily_at' => '02:00',
+ 'enabled' => 0,
+ 'interval_safe' => false,
+ ],
[
'name' => 'certificate_refresher',
'label' => 'Certificate Refresher',
diff --git a/scripts/restore_cli.php b/scripts/restore_cli.php
new file mode 100644
index 00000000..59e00a83
--- /dev/null
+++ b/scripts/restore_cli.php
@@ -0,0 +1,136 @@
+ Backup on that install.\n");
+ exit(1);
+}
+
+$error = null;
+$meta = backupInspectArchive($file, $key, $error);
+
+if ($meta === false) {
+ fwrite(STDERR, "Cannot read this backup: $error\n");
+ exit(1);
+}
+
+echo "Backup archive: " . basename($file) . "\n";
+echo "Size: " . backupFormatBytes(filesize($file)) . "\n";
+echo "Type: " . backupTypeLabel($meta['type']) . "\n";
+echo "Taken: " . $meta['generated'] . "\n";
+echo "ITFlow version: " . $meta['app_version'] . "\n";
+echo "Database version:" . " " . $meta['database_version'] . "\n";
+echo "Contains: " . implode(", ", $meta['entries']) . "\n";
+
+$current_db_version = backupCurrentDatabaseVersion($mysqli);
+echo "This install is at database version $current_db_version.\n";
+
+if (isset($options['inspect'])) {
+ echo "\n--inspect given, nothing was changed.\n";
+ exit(0);
+}
+
+if ($meta['database_version'] !== 'Unknown' && version_compare($meta['database_version'], $current_db_version, '>')) {
+ echo "\nWARNING: this backup is from a NEWER database version than the code in this directory.\n";
+ echo "Update ITFlow to a matching version before restoring, or the app will error after the restore.\n";
+}
+
+echo "\n";
+echo "This will REPLACE the database";
+if (in_array('uploads.zip', $meta['entries'], true)) {
+ echo " and everything in the uploads folder";
+}
+echo ".\n";
+echo "The current database is dumped first and put back automatically if the restore fails.\n";
+
+if (!isset($options['yes'])) {
+ echo "\nType 'restore' to continue: ";
+ $answer = trim(fgets(STDIN) ?: '');
+ if ($answer !== 'restore') {
+ echo "Aborted, nothing was changed.\n";
+ exit(1);
+ }
+}
+
+echo "\n";
+
+$restore_error = null;
+$ok = backupRestoreArchive($mysqli, $file, $key, $restore_error, function ($message) {
+ echo " $message\n";
+});
+
+if (!$ok) {
+ fwrite(STDERR, "\nRestore failed: $restore_error\n");
+ exit(1);
+}
+
+logAudit("Backup", "Restore", "Restored from " . escapeSql(basename($file)) . " via the command line");
+
+echo "\nDone.\n";
+echo "\nNext steps:\n";
+echo " 1. Log in with the credentials that were in use when the backup was taken.\n";
+echo " 2. If the backup is older than the code in this directory, finish the database update:\n";
+echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n";
+echo " 3. Check Settings > Cron - the schedule came back with the database.\n";
+
+exit(0);
diff --git a/setup/index.php b/setup/index.php
index 06a6d0a1..a1e5ce7b 100644
--- a/setup/index.php
+++ b/setup/index.php
@@ -8,26 +8,43 @@ if (file_exists("../config.php")) {
include "../functions.php"; // Global Functions
include "../includes/database_version.php";
-if (!isset($config_enable_setup)) {
- $config_enable_setup = 1;
-}
-
-if ($config_enable_setup == 0) {
- header("Location: /login.php");
- exit;
-}
-
$mysqli_available = isset($mysqli) && $mysqli instanceof mysqli;
$can_show_restore = false;
$should_skip_to_user = false;
+/*
+ * An install with users in it is a live install, and setup is closed on one whatever
+ * config.php says.
+ *
+ * This used to default $config_enable_setup to 1 when the flag was absent, which fails the
+ * wrong way: config.php is written when the database step completes but the flag is only
+ * appended at the very end of a successful run, so an install abandoned in between - or one
+ * where that final append failed - left the restore below reachable with no authentication
+ * at all. That endpoint drops every table and imports whatever archive it is handed, and it
+ * rewrites the uploads directory, including the .htaccess that stops PHP running there.
+ */
+$install_is_live = false;
+
if (file_exists("../config.php") && $mysqli_available) {
$table_result = mysqli_query($mysqli, "SHOW TABLES LIKE 'users'");
if ($table_result && mysqli_num_rows($table_result) > 0) {
- $can_show_restore = true;
$should_skip_to_user = true;
- } else {
- // If DB exists but doesn't have user table yet, maybe still allow restore
+
+ $user_count_result = mysqli_query($mysqli, "SELECT COUNT(*) AS user_count FROM users");
+ if ($user_count_result) {
+ $user_count_row = mysqli_fetch_assoc($user_count_result);
+ if (intval($user_count_row['user_count']) > 0) {
+ $install_is_live = true;
+ }
+ } else {
+ // Cannot prove the install is empty, so treat it as live
+ $install_is_live = true;
+ }
+ }
+
+ // Restore needs a database connection and an empty install. A populated one restores
+ // from the command line instead - scripts/restore_cli.php.
+ if (!$install_is_live) {
$all_tables = mysqli_query($mysqli, "SHOW TABLES");
if ($all_tables && mysqli_num_rows($all_tables) > 0) {
$can_show_restore = true;
@@ -35,6 +52,15 @@ if (file_exists("../config.php") && $mysqli_available) {
}
}
+if (!isset($config_enable_setup)) {
+ $config_enable_setup = $install_is_live ? 0 : 1;
+}
+
+if ($config_enable_setup == 0 || $install_is_live) {
+ header("Location: /login.php");
+ exit;
+}
+
include_once "../includes/settings_localization_array.php";
$errorLog = ini_get('error_log') ?: "Debian/Ubuntu default is usually /var/log/apache2/error.log";
@@ -126,239 +152,93 @@ if (isset($_POST['add_database'])) {
if (isset($_POST['restore'])) {
- // ---------- Long-running guards ----------
- @set_time_limit(0);
- if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); }
-
- // ---------- Minimal helpers (scoped) ----------
- if (!function_exists('deleteDir')) {
- function deleteDir($dir) {
- if (!is_dir($dir)) return;
- $it = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST
- );
- foreach ($it as $item) {
- $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
- }
- @rmdir($dir);
- }
+ // Belt and braces: the page-level gate above already sends a live install to the login
+ // page, but this handler is the destructive one, so it re-checks rather than trusting
+ // that it was only reached through the form.
+ if ($install_is_live || !$can_show_restore) {
+ $_SESSION['alert_message'] = "This install already has users. Restore over it from the command line instead: php scripts/restore_cli.php --file=/path/to/backup.zip";
+ header("Location: ?restore");
+ exit;
}
- if (!function_exists('importSqlFile')) {
- /**
- * Import a SQL file via mysqli, supports 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");
+ // An upload larger than post_max_size arrives with $_FILES and $_POST both empty, so
+ // PHP cannot tell us which field failed - it is worth naming, because a real full
+ // backup is usually bigger than the limit and the old message just said the upload
+ // failed.
+ $max_upload_bytes = backupMaxUploadBytes();
+ $content_length = intval($_SERVER['CONTENT_LENGTH'] ?? 0);
- $delimiter = ';';
- $statement = '';
-
- while (($line = fgets($fh)) !== false) {
- $trim = trim($line);
-
- // Skip comments/empty
- 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);
- }
+ $too_large = false;
+ if (!isset($_FILES['backup_zip']) && $content_length > 0 && $max_upload_bytes > 0 && $content_length > $max_upload_bytes) {
+ $too_large = true;
+ } elseif (isset($_FILES['backup_zip']) && in_array($_FILES['backup_zip']['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) {
+ $too_large = true;
+ }
+
+ if ($too_large) {
+ $_SESSION['alert_message'] = "That backup is too large to upload through a browser (this server accepts up to "
+ . backupFormatBytes($max_upload_bytes)
+ . "). Restore it from the command line instead - there is no size limit there. Copy the backup onto this server and run: php "
+ . dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip";
+ header("Location: ?restore");
+ exit;
}
- // ---------- 1) Validate uploaded backup ----------
if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) {
- die("No backup file uploaded or upload failed.");
+ $_SESSION['alert_message'] = "No backup file was uploaded, or the upload failed.";
+ header("Location: ?restore");
+ exit;
}
- $file = $_FILES['backup_zip'];
- $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
- if ($fileExt !== "zip") {
- die("Only .zip files are allowed.");
+ if (strtolower(pathinfo($_FILES['backup_zip']['name'], PATHINFO_EXTENSION)) !== 'zip') {
+ $_SESSION['alert_message'] = "Only .zip backup archives can be restored.";
+ header("Location: ?restore");
+ exit;
}
- // ---------- 2) Save to secure temp ----------
- $tempZip = tempnam(sys_get_temp_dir(), "restore_");
- if (!move_uploaded_file($file["tmp_name"], $tempZip)) {
- die("Failed to save uploaded backup file.");
- }
- @chmod($tempZip, 0600);
-
- $zip = new ZipArchive;
- if ($zip->open($tempZip) !== TRUE) {
- @unlink($tempZip);
- die("Failed to open backup zip file.");
+ // The key belongs to the install that MADE the backup, which on a rebuilt server is not
+ // this one, so it is asked for rather than read from config.php.
+ $restore_key = trim($_POST['backup_key'] ?? '');
+ if ($restore_key === '') {
+ $restore_key = $config_backup_key ?? '';
}
- // ---------- 3) Guard & extract OUTER zip ----------
- $tempDir = sys_get_temp_dir() . "/restore_temp_" . uniqid("", true);
- if (!mkdir($tempDir, 0700, true)) {
- $zip->close();
- @unlink($tempZip);
- die("Failed to create temp directory.");
+ if ($restore_key === '') {
+ $_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Settings > Backup on the install that made this archive.";
+ header("Location: ?restore");
+ exit;
}
- // Zip-slip guard (outer)
- for ($i = 0; $i < $zip->numFiles; $i++) {
- $name = $zip->getNameIndex($i);
- if ($name === false) continue;
- if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
- $zip->close();
- @unlink($tempZip);
- deleteDir($tempDir);
- die("Invalid file path in outer ZIP.");
- }
+ $temp_zip = tempnam(sys_get_temp_dir(), "itflow_restore_upload_");
+ if (!move_uploaded_file($_FILES['backup_zip']['tmp_name'], $temp_zip)) {
+ @unlink($temp_zip);
+ $_SESSION['alert_message'] = "Could not save the uploaded backup file.";
+ header("Location: ?restore");
+ exit;
+ }
+ @chmod($temp_zip, 0600);
+
+ $restore_error = null;
+ $restored = backupRestoreArchive($mysqli, $temp_zip, $restore_key, $restore_error);
+
+ @unlink($temp_zip);
+
+ if (!$restored) {
+ $_SESSION['alert_message'] = $restore_error;
+ header("Location: ?restore");
+ exit;
}
- if (!$zip->extractTo($tempDir)) {
- $zip->close();
- @unlink($tempZip);
- deleteDir($tempDir);
- die("Failed to extract backup contents.");
- }
-
- $zip->close();
- @unlink($tempZip);
-
- // ---------- 4) Restore SQL (via PHP, no CLI) ----------
- $sqlPath = "$tempDir/db.sql";
- if (file_exists($sqlPath)) {
- // Drop-all first (foreign key safe)
- mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0");
- $tables = mysqli_query($mysqli, "SHOW TABLES");
- if ($tables) {
- while ($row = mysqli_fetch_row($tables)) {
- mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $row[0] . "`");
- }
- }
- 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'));
- }
+ // Close setup behind us. The gate above would now do this on its own because the
+ // restored database has users, but the flag is what stops the wizard being reachable
+ // at all.
+ $config_path = __DIR__ . "/../config.php";
+ if (@file_put_contents($config_path, "\n\$config_enable_setup = 0;\n\n", FILE_APPEND | LOCK_EX) === false) {
+ $_SESSION['alert_message'] = "Backup restored, but config.php could not be updated - please set \$config_enable_setup = 0 in it by hand.";
} else {
- deleteDir($tempDir);
- die("Missing db.sql in the backup archive.");
+ $_SESSION['alert_message'] = "Backup restored. Log in with the credentials that were in use when the backup was taken.";
}
- // ---------- 5) Restore uploads directory ----------
- $uploadDir = rtrim(__DIR__ . "/../uploads", '/\\') . '/';
- $uploadsZip = "$tempDir/uploads.zip";
-
- if (!file_exists($uploadsZip)) {
- deleteDir($tempDir);
- die("Missing uploads.zip in the backup archive.");
- }
-
- $uploads = new ZipArchive;
- if ($uploads->open($uploadsZip) !== TRUE) {
- deleteDir($tempDir);
- die("Failed to open uploads.zip in backup.");
- }
-
- // Zip-slip guard (inner)
- for ($i = 0; $i < $uploads->numFiles; $i++) {
- $name = $uploads->getNameIndex($i);
- if ($name === false) continue;
- if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
- $uploads->close();
- deleteDir($tempDir);
- die("Invalid file path in uploads.zip.");
- }
- }
-
- // Ensure uploads dir exists then clean it
- if (!is_dir($uploadDir)) {
- if (!mkdir($uploadDir, 0750, true)) {
- $uploads->close();
- deleteDir($tempDir);
- die("Failed to create uploads directory.");
- }
- } else {
- foreach (new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::CHILD_FIRST
- ) as $item) {
- $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
- }
- }
-
- // Extract uploads.zip directly into /uploads (your original, working behavior)
- if (!$uploads->extractTo($uploadDir)) {
- $uploads->close();
- deleteDir($tempDir);
- die("Failed to extract uploads.zip into uploads directory.");
- }
- $uploads->close();
-
- // Verify uploads isn’t empty
- $hasFiles = false;
- $fileCount = 0; $dirCount = 0;
- if (is_dir($uploadDir)) {
- $it = new RecursiveIteratorIterator(
- new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS),
- RecursiveIteratorIterator::SELF_FIRST
- );
- foreach ($it as $node) {
- if ($node->isDir()) $dirCount++;
- else { $fileCount++; $hasFiles = true; }
- }
- }
- if (!$hasFiles) {
- deleteDir($tempDir);
- die("Uploads restore appears empty after extraction.");
- }
-
- // ---------- 6) Optional: version info ----------
- $versionTxt = "$tempDir/version.txt";
- if (file_exists($versionTxt)) {
- $versionInfo = @file_get_contents($versionTxt);
- if ($versionInfo !== false) {
- logAudit("Backup Restore", "Version Info", $versionInfo);
- }
- }
-
- // ---------- 7) Cleanup temp ----------
- deleteDir($tempDir);
-
- // ---------- 8) Finalize setup flag (append safely) ----------
- $configPath = __DIR__ . "/../config.php";
- $append = "\n\$config_enable_setup = 0;\n\n";
- if (!@file_put_contents($configPath, $append, FILE_APPEND | LOCK_EX)) {
- $_SESSION['alert_message'] = "Backup restored ($fileCount files, $dirCount folders), but couldn't update setup flag — please set \$config_enable_setup = 0 in config.php.";
- } else {
- $_SESSION['alert_message'] = "Full backup restored successfully ($fileCount files, $dirCount folders).";
- }
-
- // ---------- 9) Done ----------
header("Location: ../login.php");
exit;
}
@@ -1264,10 +1144,27 @@ if (isset($_POST['add_telemetry'])) {
Restore from Backup