mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
1440 lines
48 KiB
PHP
1440 lines
48 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 Maintenance > 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 Maintenance > 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];
|
|
}
|