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'"); $backup_id = intval(mysqli_insert_id($mysqli)); // Ask the dispatcher to run the backup job on its next pass. Without this the row sits // Pending for ever on a default install: the job ships disabled, so the schedule never // calls it and nothing ever builds what the button just queued. run_now is honoured // whether or not a job is enabled, which is exactly the case this needs. mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_run_now = 1 WHERE cron_job_name = 'backup'"); return $backup_id; } /** * 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"); } } } // Reconciliation runs BEFORE the retention maths, not after. A row this rescues or // adopts has to be counted by the same pass that decides what to delete - otherwise it // escapes retention until tomorrow, and a second run on the same day is not the no-op // CONTRIBUTING's third cron rule asks for. // 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']++; } // Keep the newest $count complete backups OF EACH TYPE, regardless of age. // // Per type rather than one shared pool: a master key export is a few hundred bytes and // a full backup is gigabytes, so counting them together means five master key exports // silently evict every real backup on the box. They are also the artefact you would // least want retention to quietly remove. $keep = []; foreach (backupAllTypes() as $keep_type) { $keep_type_esc = escapeSql($keep_type); $keep_res = mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_status = 'Complete' AND backup_type = '$keep_type_esc' 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']++; } } } 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]; }