mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
Backups are now AES-256 encrypted zips in three types (full, database only, master key), catalogued in a new backups table, built by cron rather than the web request, and kept under uploads/backups with retention in the nightly job. The encryption key is one value per install held in config.php, never in the database and never in the file name. Restore is shared by the setup wizard and the new scripts/restore_cli.php, which is the only path without an upload size limit. It verifies the key and unpacks the archive before dropping anything, and dumps the current database first so a failed import is rolled back. A backup dumps, zips and encrypts for minutes without issuing a query, so on a server with a short wait_timeout the connection is closed underneath it and the UPDATE marking the backup complete is what fails - long after the archive was written correctly. The connection is now held open for the job and re-established before any write that follows long file work, including the database phase of a restore. Retention recovers rows a dropped connection left behind: still Running after six hours becomes Complete if the archive is on disk, Failed if it is not. cron.php's own failure path is hardened to match. It recorded job failures through the same connection the failing job had just killed, so an uncaught exception ended the dispatch and no trace of the original error survived. Failures now also echo to stdout, so cron mails something useful when the database is unreachable. Security: the setup wizard's restore step is now closed on any install that has users, whatever config.php says. $config_enable_setup defaulted to enabled when the flag was absent, and the flag is only written at the end of a successful install, so an install abandoned partway left an unauthenticated endpoint that would drop every table, import an attacker-supplied archive, and overwrite uploads/ including the .htaccess that stops PHP running there. Affects 26.07 and earlier. Restoring over a live install is now CLI only.
1419 lines
47 KiB
PHP
1419 lines
47 KiB
PHP
<?php
|
|
|
|
// Backup creation, storage, retention and restore
|
|
// Shared by the admin pages, cron/backup.php and scripts/restore_cli.php
|
|
//
|
|
// Loaded from both sides: the admin tier requires it in a web request and cron/backup.php
|
|
// and scripts/restore_cli.php require it on the command line. Nothing in here may touch
|
|
// $_SERVER, $_SESSION or any other superglobal - there is no DOCUMENT_ROOT, no session and
|
|
// no request when cron runs it.
|
|
//
|
|
// The archive is a zip whose entries are encrypted with WinZip AES-256. The key is a single
|
|
// per-install value kept in config.php and NEVER in the database, so a backup that leaks
|
|
// (from the web root, from a synced folder, from a stolen laptop) cannot be opened with
|
|
// anything the database contains. It is not in the file name either - the random token in
|
|
// the name is only there to make the path unguessable.
|
|
|
|
DEFINE("BACKUP_TYPE_FULL", "full");
|
|
DEFINE("BACKUP_TYPE_DATABASE", "database");
|
|
DEFINE("BACKUP_TYPE_MASTER_KEY", "master_key");
|
|
|
|
/**
|
|
* The backup types that can be produced without a logged-in user.
|
|
* master_key needs the session master key, so cron can never make one.
|
|
*/
|
|
function backupUnattendedTypes(): array
|
|
{
|
|
return [BACKUP_TYPE_FULL, BACKUP_TYPE_DATABASE];
|
|
}
|
|
|
|
function backupAllTypes(): array
|
|
{
|
|
return [BACKUP_TYPE_FULL, BACKUP_TYPE_DATABASE, BACKUP_TYPE_MASTER_KEY];
|
|
}
|
|
|
|
function backupTypeLabel(string $type): string
|
|
{
|
|
switch ($type) {
|
|
case BACKUP_TYPE_FULL:
|
|
return "Full Backup";
|
|
case BACKUP_TYPE_DATABASE:
|
|
return "Database Only";
|
|
case BACKUP_TYPE_MASTER_KEY:
|
|
return "Master Key";
|
|
}
|
|
return $type;
|
|
}
|
|
|
|
/**
|
|
* Absolute path of the ITFlow root, worked out from this file rather than DOCUMENT_ROOT
|
|
* so it is identical under the web server and under cron.
|
|
*/
|
|
function backupAppRoot(): string
|
|
{
|
|
return dirname(__DIR__);
|
|
}
|
|
|
|
/**
|
|
* The encryption key for this install.
|
|
*
|
|
* Lives in config.php only. Generated and appended on first use so nobody has to think
|
|
* about it, then displayed in Settings > 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, "<?php // Silence is golden\n");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* uploads/ carries its own guards, and a restore wipes the directory before extracting.
|
|
* A backup taken before those guards existed would therefore silently remove them, so
|
|
* they are rewritten after every restore whatever the archive contained.
|
|
*/
|
|
function backupAssertUploadsGuards(): void
|
|
{
|
|
$uploads = backupAppRoot() . "/uploads";
|
|
|
|
if (!is_dir($uploads)) {
|
|
@mkdir($uploads, 0750, true);
|
|
}
|
|
|
|
$htaccess = $uploads . "/.htaccess";
|
|
$wanted = "Options -ExecCGI -Indexes\nphp_flag engine off\nRemoveHandler .php .phtml .phar .phps\nRemoveType .php .phtml .phar .phps\n<FilesMatch \"\\.(php|phtml|phar|phps|cgi|pl|sh)\$\">\n Require all denied\n</FilesMatch>\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];
|
|
}
|