mirror of
https://github.com/itflow-org/itflow
synced 2026-08-05 07:07:14 +00:00
Encrypted backups with types, scheduling and CLI restore
Backups are now AES-256 encrypted zips in three types (full, database only, master key), catalogued in a new backups table, built by cron rather than the web request, and kept under uploads/backups with retention in the nightly job. The encryption key is one value per install held in config.php, never in the database and never in the file name. Restore is shared by the setup wizard and the new scripts/restore_cli.php, which is the only path without an upload size limit. It verifies the key and unpacks the archive before dropping anything, and dumps the current database first so a failed import is rolled back. A backup dumps, zips and encrypts for minutes without issuing a query, so on a server with a short wait_timeout the connection is closed underneath it and the UPDATE marking the backup complete is what fails - long after the archive was written correctly. The connection is now held open for the job and re-established before any write that follows long file work, including the database phase of a restore. Retention recovers rows a dropped connection left behind: still Running after six hours becomes Complete if the archive is on disk, Failed if it is not. cron.php's own failure path is hardened to match. It recorded job failures through the same connection the failing job had just killed, so an uncaught exception ended the dispatch and no trace of the original error survived. Failures now also echo to stdout, so cron mails something useful when the database is unreachable. Security: the setup wizard's restore step is now closed on any install that has users, whatever config.php says. $config_enable_setup defaulted to enabled when the flag was absent, and the flag is only written at the end of a successful install, so an install abandoned partway left an unauthenticated endpoint that would drop every table, import an attacker-supplied archive, and overwrite uploads/ including the .htaccess that stops PHP running there. Affects 26.07 and earlier. Restoring over a live install is now CLI only.
This commit is contained in:
282
admin/backup.php
282
admin/backup.php
@@ -1,32 +1,287 @@
|
||||
<?php
|
||||
|
||||
require_once "includes/inc_all_admin.php";
|
||||
|
||||
$backup_key = backupEncryptionKey();
|
||||
$backup_dir = backupStorageDir();
|
||||
|
||||
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at, config_backup_retention_days, config_backup_retention_count, config_backup_cron_type FROM settings WHERE company_id = 1"));
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
$cron_last_dispatch_at = $row['config_cron_last_dispatch_at'];
|
||||
$config_backup_retention_days = intval($row['config_backup_retention_days']);
|
||||
$config_backup_retention_count = intval($row['config_backup_retention_count']);
|
||||
$config_backup_cron_type = $row['config_backup_cron_type'];
|
||||
|
||||
// Same heartbeat rule as Settings > Cron - archives are built by the dispatcher, so a dead
|
||||
// crontab means the buttons below queue work that never runs
|
||||
$cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300;
|
||||
|
||||
$backup_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_enabled, cron_job_daily_at FROM cron_jobs WHERE cron_job_name = 'backup'"));
|
||||
|
||||
$backups = mysqli_query($mysqli, "SELECT * FROM backups ORDER BY backup_created_at DESC LIMIT 100");
|
||||
|
||||
$pending_count = intval(mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS c FROM backups WHERE backup_status IN ('Pending','Running')"))['c']);
|
||||
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Shown once, immediately after an export, then dropped - it must not survive a refresh
|
||||
if (!empty($_SESSION['backup_master_key_reveal'])) {
|
||||
$master_key_reveal = $_SESSION['backup_master_key_reveal'];
|
||||
unset($_SESSION['backup_master_key_reveal']);
|
||||
?>
|
||||
<div class="alert alert-warning">
|
||||
<h5><i class="fas fa-fw fa-key mr-2"></i>Master encryption key</h5>
|
||||
<p class="mb-2">Shown once. Refreshing this page will not show it again.</p>
|
||||
<input type="text" class="form-control text-monospace" value="<?= escapeHtml($master_key_reveal) ?>" readonly onclick="this.select();">
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php if ($backup_key === '') { ?>
|
||||
<div class="alert alert-danger">
|
||||
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>No backup encryption key</h5>
|
||||
ITFlow could not write a backup encryption key to <strong>config.php</strong>, so it cannot produce an encrypted backup.
|
||||
Make config.php writable by the web server user and reload this page, or add a line like
|
||||
<code>$config_backup_key = '<32 random characters>';</code> to it yourself.
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<?php if (!$cron_is_running) { ?>
|
||||
<div class="alert alert-danger">
|
||||
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Cron is not running</h5>
|
||||
Backups are built by the cron dispatcher, not by your browser. Until cron is running, anything you
|
||||
start here will sit in the queue. See <a href="cron.php">Settings > Cron</a>.
|
||||
</div>
|
||||
<?php } elseif ($config_enable_cron == 0) { ?>
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off in
|
||||
<a href="settings_notification.php">Settings > Notifications</a>.
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="card card-dark mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-database mr-2"></i>Download Database</h3>
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-cloud-upload-alt mr-2"></i>Create a Backup</h3>
|
||||
</div>
|
||||
<div class="card-body" style="text-align: center;">
|
||||
<div class="alert alert-secondary">If you are unable to back up the entire VM, you'll need to back up the files & database individually. There is no built-in restore. See the <a href="https://docs.itflow.org/backups" target="_blank">docs here</a>.</div>
|
||||
<a class="btn btn-primary btn-lg p-3" href="post.php?download_backup&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-4x fa-download"></i><br><br>Download Backup</a>
|
||||
<div class="card-body">
|
||||
|
||||
<?php if ($pending_count > 0) { ?>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-fw fa-spinner mr-2"></i><strong><?= $pending_count ?></strong> backup<?= $pending_count == 1 ? ' is' : 's are' ?>
|
||||
queued or building. You will get a notification when ready - this page does not refresh itself.
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="border rounded p-3 h-100 text-center">
|
||||
<i class="fas fa-fw fa-3x fa-box-open text-dark mb-3"></i>
|
||||
<h5>Full Backup</h5>
|
||||
<p class="text-muted small">The database and everything in the uploads folder. This is the one to keep.</p>
|
||||
<a class="btn btn-primary <?= $backup_key === '' ? 'disabled' : '' ?>" href="post.php?queue_backup=full&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-play mr-2"></i>Start
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="border rounded p-3 h-100 text-center">
|
||||
<i class="fas fa-fw fa-3x fa-database text-dark mb-3"></i>
|
||||
<h5>Database Only</h5>
|
||||
<p class="text-muted small">Just the SQL dump. Much smaller and much quicker, but no attachments or documents.</p>
|
||||
<a class="btn btn-primary <?= $backup_key === '' ? 'disabled' : '' ?>" href="post.php?queue_backup=database&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-play mr-2"></i>Start
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="border rounded p-3 h-100 text-center">
|
||||
<i class="fas fa-fw fa-3x fa-key text-dark mb-3"></i>
|
||||
<h5>Master Key</h5>
|
||||
<p class="text-muted small">The credential vault key. Only needed if every user password is lost - a normal restore recovers the vault on its own.</p>
|
||||
<button type="button" class="btn btn-secondary" data-toggle="modal" data-target="#masterKeyModal">
|
||||
<i class="fas fa-fw fa-key mr-2"></i>Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-dark mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-archive mr-2"></i>Backups</h3>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-borderless mb-0">
|
||||
<thead class="text-dark">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (mysqli_num_rows($backups) === 0) { ?>
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">No backups yet.</td></tr>
|
||||
<?php } ?>
|
||||
<?php while ($backup = mysqli_fetch_assoc($backups)) {
|
||||
|
||||
$backup_id = intval($backup['backup_id']);
|
||||
$status = $backup['backup_status'];
|
||||
|
||||
$badge = 'secondary';
|
||||
if ($status === 'Complete') { $badge = 'success'; }
|
||||
if ($status === 'Failed') { $badge = 'danger'; }
|
||||
if ($status === 'Missing') { $badge = 'warning'; }
|
||||
if ($status === 'Running' || $status === 'Pending') { $badge = 'info'; }
|
||||
?>
|
||||
<tr>
|
||||
<td><?= escapeHtml(backupTypeLabel($backup['backup_type'])) ?></td>
|
||||
<td><?= escapeHtml($backup['backup_created_at']) ?></td>
|
||||
<td><?= $backup['backup_size'] > 0 ? escapeHtml(backupFormatBytes($backup['backup_size'])) : '-' ?></td>
|
||||
<td><?= escapeHtml($backup['backup_source']) ?></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= $badge ?>"><?= escapeHtml($status) ?></span>
|
||||
<?php if (!empty($backup['backup_error'])) { ?>
|
||||
<br><small class="text-danger"><?= escapeHtml($backup['backup_error']) ?></small>
|
||||
<?php } ?>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<?php if ($status === 'Complete') { ?>
|
||||
<a class="btn btn-sm btn-primary" href="backup_download.php?backup_id=<?= $backup_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-download"></i>
|
||||
</a>
|
||||
<?php } ?>
|
||||
<a class="btn btn-sm btn-danger confirm-link" href="post.php?delete_backup=<?= $backup_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
|
||||
<i class="fas fa-fw fa-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-dark mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-lock mr-2"></i>Encryption Key</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
|
||||
<strong>Write this down and keep it somewhere other than this server.</strong>
|
||||
Every backup is encrypted with it, and without it a backup cannot be restored - not by you,
|
||||
not by anyone. It is stored in config.php and never in the database, which is what stops a
|
||||
stolen backup from carrying its own key.
|
||||
</div>
|
||||
|
||||
<?php if ($backup_key !== '') { ?>
|
||||
<div class="input-group col-md-6 px-0">
|
||||
<input type="text" class="form-control text-monospace" value="<?= escapeHtml($backup_key) ?>" readonly onclick="this.select();">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-secondary" type="button" onclick="navigator.clipboard.writeText('<?= escapeHtml($backup_key) ?>');">
|
||||
<i class="fas fa-fw fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
Archives are AES-256 encrypted zips. <strong>7-Zip, WinZip, PeaZip and Keka</strong> can open them with this key.
|
||||
The <code>unzip</code> command, Windows Explorer and the macOS Archive Utility cannot - they do not support AES.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-dark mb-3">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-clock mr-2"></i>Scheduled Backups & Retention</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="post.php" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-4">
|
||||
<label>Scheduled backup type</label>
|
||||
<select class="form-control" name="config_backup_cron_type">
|
||||
<option <?= $config_backup_cron_type === 'full' ? 'selected' : '' ?> value="full">Full Backup</option>
|
||||
<option <?= $config_backup_cron_type === 'database' ? 'selected' : '' ?> value="database">Database Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label>Keep backups for (days)</label>
|
||||
<input type="number" class="form-control" name="config_backup_retention_days" min="0" value="<?= intval($config_backup_retention_days) ?>">
|
||||
<small class="text-muted">0 disables age-based deletion.</small>
|
||||
</div>
|
||||
<div class="form-group col-md-4">
|
||||
<label>Keep at most (backups)</label>
|
||||
<input type="number" class="form-control" name="config_backup_retention_count" min="1" value="<?= intval($config_backup_retention_count) ?>">
|
||||
<small class="text-muted">The newest is never deleted.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="edit_backup_settings" class="btn btn-primary"><i class="fas fa-fw fa-check mr-2"></i>Save</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<p class="mb-0">
|
||||
<?php if (!empty($backup_job) && intval($backup_job['cron_job_enabled']) === 1) { ?>
|
||||
<i class="fas fa-fw fa-check text-success mr-2"></i>Scheduled backups run daily at
|
||||
<strong><?= escapeHtml(substr((string)$backup_job['cron_job_daily_at'], 0, 5)) ?></strong>.
|
||||
<?php } else { ?>
|
||||
<i class="fas fa-fw fa-times text-danger mr-2"></i>Scheduled backups are switched off.
|
||||
<?php } ?>
|
||||
Turn them on or change the time in <a href="cron.php">Settings > Cron</a>.
|
||||
</p>
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
Old backups are removed by the nightly job, never by the backup itself, so a failed nightly
|
||||
cannot delete an archive that was never replaced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-dark">
|
||||
<div class="card-header py-3">
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-key mr-2"></i>Backup Master Encryption Key</h3>
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-undo mr-2"></i>Restoring</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body">
|
||||
<form action="post.php" method="POST">
|
||||
<p>Restoring replaces the database and the uploads folder with what is in the archive. It cannot be done from here, on purpose - a running install is the wrong place to be dropping its own tables from a browser.</p>
|
||||
<p class="mb-2"><strong>From the command line</strong> - the only option that works for large backups:</p>
|
||||
<pre class="bg-dark text-white p-2"><?= escapeHtml("php " . dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip") ?></pre>
|
||||
<p class="mb-0"><strong>From a browser</strong>, on a fresh install only, the setup wizard has a restore step at <code>/setup</code>. Once an install has users, that step closes itself.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="masterKeyModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-fw fa-key mr-2"></i>Export Master Key</h5>
|
||||
<button type="button" class="close" data-dismiss="modal"><span>×</span></button>
|
||||
</div>
|
||||
<form action="post.php" method="POST" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="input-group col-sm-4">
|
||||
<input type="password" class="form-control" placeholder="Enter your account password" name="password" autocomplete="new-password" required>
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-primary" type="submit" name="backup_master_key"><i class="fas fa-key"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
This key decrypts every credential in this install. It is shown on screen and is not written anywhere.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Confirm your account password</label>
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" name="backup_master_key" class="btn btn-primary"><i class="fas fa-fw fa-key mr-2"></i>Show Master Key</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -35,4 +290,3 @@ require_once "includes/inc_all_admin.php";
|
||||
|
||||
<?php
|
||||
require_once "../includes/footer.php";
|
||||
|
||||
|
||||
61
admin/backup_download.php
Normal file
61
admin/backup_download.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - GET /admin/backup_download.php
|
||||
* Streams a backup archive to an administrator
|
||||
*
|
||||
* Deliberately NOT agent/file.php: that gates on module_client and resolves paths under
|
||||
* uploads/clients/<id>/, which would hand a full database dump to any agent with client
|
||||
* read access. A backup is an admin artifact and gets an admin-only path of its own.
|
||||
*/
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../functions.php";
|
||||
require_once "../includes/check_login.php";
|
||||
|
||||
enforceAdminPermission();
|
||||
validateCSRFToken();
|
||||
|
||||
if (!isset($_GET['backup_id'])) {
|
||||
http_response_code(400);
|
||||
exit("Backup ID required");
|
||||
}
|
||||
|
||||
$backup_id = intval($_GET['backup_id']);
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM backups WHERE backup_id = $backup_id AND backup_status = 'Complete' LIMIT 1");
|
||||
|
||||
if (mysqli_num_rows($sql) !== 1) {
|
||||
http_response_code(404);
|
||||
exit("Backup not found");
|
||||
}
|
||||
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
$file_path = backupResolvePath($row['backup_file_name']);
|
||||
|
||||
if ($file_path === false || !is_file($file_path)) {
|
||||
mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Missing' WHERE backup_id = $backup_id");
|
||||
http_response_code(404);
|
||||
exit("Backup file is no longer on disk");
|
||||
}
|
||||
|
||||
$file_name = basename($file_path);
|
||||
|
||||
logAudit("Backup", "Download", ($session_name ?? 'Unknown User') . " downloaded backup " . escapeSql($file_name));
|
||||
mysqli_query($mysqli, "UPDATE backups SET backup_downloaded_at = NOW() WHERE backup_id = $backup_id");
|
||||
|
||||
header("Content-Type: application/zip");
|
||||
header("Content-Disposition: attachment; filename=\"$file_name\"");
|
||||
header("Content-Length: " . filesize($file_path));
|
||||
header("X-Content-Type-Options: nosniff");
|
||||
header("Cache-Control: private, no-store");
|
||||
header("Pragma: no-cache");
|
||||
|
||||
// Clear output buffers so a multi-gigabyte archive streams instead of loading into memory
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
readfile($file_path);
|
||||
exit;
|
||||
38
admin/database_updates/2.6.4.php
Normal file
38
admin/database_updates/2.6.4.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Database update to version 2.6.4 (from 2.6.3)
|
||||
* Included by admin/database_updates.php - do not access directly
|
||||
*/
|
||||
|
||||
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
|
||||
|
||||
// Backup catalogue - one row per archive produced, so the app knows what exists
|
||||
// without trusting a directory listing
|
||||
|
||||
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `backups` (
|
||||
`backup_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`backup_type` varchar(20) NOT NULL DEFAULT 'full',
|
||||
`backup_file_name` varchar(255) NOT NULL,
|
||||
`backup_size` bigint(20) NOT NULL DEFAULT 0,
|
||||
`backup_sha256` varchar(64) DEFAULT NULL,
|
||||
`backup_status` varchar(20) NOT NULL DEFAULT 'Pending',
|
||||
`backup_error` text DEFAULT NULL,
|
||||
`backup_source` varchar(20) NOT NULL DEFAULT 'Manual',
|
||||
`backup_created_by` varchar(200) DEFAULT NULL,
|
||||
`backup_created_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`backup_completed_at` datetime DEFAULT NULL,
|
||||
`backup_downloaded_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`backup_id`),
|
||||
KEY `backup_status_created` (`backup_status`, `backup_created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
|
||||
|
||||
// Retention and what the scheduled backup produces
|
||||
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_retention_days` int(11) NOT NULL DEFAULT 30");
|
||||
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_retention_count` int(11) NOT NULL DEFAULT 5");
|
||||
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full'");
|
||||
|
||||
// Seed the scheduled backup job. The dispatcher would create this row itself the first
|
||||
// time it sees the job, but seeding it here means the schedule is right on an install
|
||||
// that already has cron_jobs rows - the every-minute default bit us once already.
|
||||
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = 'backup', cron_job_enabled = 0, cron_job_schedule = 'Daily', cron_job_daily_at = '02:00'");
|
||||
@@ -1,303 +1,75 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - GET/POST request handler for DB / master key backup
|
||||
* Rewritten with streaming SQL dump, component checksums, safer zipping, and better headers.
|
||||
* ITFlow - GET/POST request handler for backups
|
||||
*
|
||||
* Archives are not built here. Everything on this page records intent and lets
|
||||
* cron/backup.php do the work - see backupQueue() for why.
|
||||
*/
|
||||
|
||||
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
|
||||
|
||||
require_once "../includes/app_version.php";
|
||||
|
||||
// --- Optional performance levers for big backups ---
|
||||
@set_time_limit(0);
|
||||
if (function_exists('ini_set')) {
|
||||
@ini_set('memory_limit', '1024M');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a line to a file handle with newline.
|
||||
*/
|
||||
function writeLine($fh, string $s): void {
|
||||
fwrite($fh, $s);
|
||||
fwrite($fh, PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a SQL dump of schema and data into $sqlFile.
|
||||
* - Tables first (DROP + CREATE + INSERTs)
|
||||
* - Views (DROP VIEW + CREATE VIEW)
|
||||
* - Triggers (DROP TRIGGER + CREATE TRIGGER)
|
||||
*
|
||||
* NOTE: Routines/events are not dumped here. Add if needed.
|
||||
*/
|
||||
function dumpDatabase(mysqli $mysqli, string $sqlFile): void {
|
||||
$fh = fopen($sqlFile, 'wb');
|
||||
if (!$fh) {
|
||||
http_response_code(500);
|
||||
exit("Cannot open dump file");
|
||||
}
|
||||
|
||||
// Preamble
|
||||
writeLine($fh, "-- UTF-8 + Foreign Key Safe Dump");
|
||||
writeLine($fh, "SET NAMES 'utf8mb4';");
|
||||
writeLine($fh, "SET FOREIGN_KEY_CHECKS = 0;");
|
||||
writeLine($fh, "SET UNIQUE_CHECKS = 0;");
|
||||
writeLine($fh, "SET AUTOCOMMIT = 0;");
|
||||
writeLine($fh, "");
|
||||
|
||||
// Gather tables and views
|
||||
$tables = [];
|
||||
$views = [];
|
||||
|
||||
$res = $mysqli->query("SHOW FULL TABLES");
|
||||
if (!$res) {
|
||||
fclose($fh);
|
||||
error_log("MySQL Error (SHOW FULL TABLES): " . $mysqli->error);
|
||||
http_response_code(500);
|
||||
exit("Error retrieving tables.");
|
||||
}
|
||||
while ($row = $res->fetch_array(MYSQLI_NUM)) {
|
||||
$name = $row[0];
|
||||
$type = strtoupper($row[1] ?? '');
|
||||
if ($type === 'VIEW') {
|
||||
$views[] = $name;
|
||||
} else {
|
||||
$tables[] = $name;
|
||||
}
|
||||
}
|
||||
$res->close();
|
||||
|
||||
// --- TABLES: structure and data ---
|
||||
foreach ($tables as $table) {
|
||||
$createRes = $mysqli->query("SHOW CREATE TABLE `{$mysqli->real_escape_string($table)}`");
|
||||
if (!$createRes) {
|
||||
error_log("MySQL Error (SHOW CREATE TABLE $table): " . $mysqli->error);
|
||||
// continue to next table
|
||||
continue;
|
||||
}
|
||||
$createRow = $createRes->fetch_assoc();
|
||||
$createSQL = array_values($createRow)[1] ?? '';
|
||||
$createRes->close();
|
||||
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "-- Table structure for `{$table}`");
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "DROP TABLE IF EXISTS `{$table}`;");
|
||||
writeLine($fh, $createSQL . ";");
|
||||
writeLine($fh, "");
|
||||
|
||||
// Dump data in a streaming fashion
|
||||
$dataRes = $mysqli->query("SELECT * FROM `{$mysqli->real_escape_string($table)}`", MYSQLI_USE_RESULT);
|
||||
if ($dataRes) {
|
||||
$wroteHeader = false;
|
||||
while ($row = $dataRes->fetch_assoc()) {
|
||||
if (!$wroteHeader) {
|
||||
writeLine($fh, "-- Dumping data for table `{$table}`");
|
||||
$wroteHeader = true;
|
||||
}
|
||||
$cols = array_map(fn($c) => '`' . $mysqli->real_escape_string($c) . '`', array_keys($row));
|
||||
$vals = array_map(
|
||||
function ($v) use ($mysqli) {
|
||||
return is_null($v) ? "NULL" : "'" . $mysqli->real_escape_string($v) . "'";
|
||||
},
|
||||
array_values($row)
|
||||
);
|
||||
writeLine($fh, "INSERT INTO `{$table}` (" . implode(", ", $cols) . ") VALUES (" . implode(", ", $vals) . ");");
|
||||
}
|
||||
$dataRes->close();
|
||||
if ($wroteHeader) writeLine($fh, "");
|
||||
}
|
||||
}
|
||||
|
||||
// --- VIEWS ---
|
||||
foreach ($views as $view) {
|
||||
$escView = $mysqli->real_escape_string($view);
|
||||
$cRes = $mysqli->query("SHOW CREATE VIEW `{$escView}`");
|
||||
if ($cRes) {
|
||||
$row = $cRes->fetch_assoc();
|
||||
$createView = $row['Create View'] ?? '';
|
||||
$cRes->close();
|
||||
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "-- View structure for `{$view}`");
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "DROP VIEW IF EXISTS `{$view}`;");
|
||||
// Ensure statement ends with semicolon
|
||||
if (!str_ends_with($createView, ';')) $createView .= ';';
|
||||
writeLine($fh, $createView);
|
||||
writeLine($fh, "");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TRIGGERS ---
|
||||
$tRes = $mysqli->query("SHOW TRIGGERS");
|
||||
if ($tRes) {
|
||||
while ($t = $tRes->fetch_assoc()) {
|
||||
$triggerName = $t['Trigger'];
|
||||
$escTrig = $mysqli->real_escape_string($triggerName);
|
||||
$crt = $mysqli->query("SHOW CREATE TRIGGER `{$escTrig}`");
|
||||
if ($crt) {
|
||||
$row = $crt->fetch_assoc();
|
||||
$createTrig = $row['SQL Original Statement'] ?? ($row['Create Trigger'] ?? '');
|
||||
$crt->close();
|
||||
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "-- Trigger for `{$triggerName}`");
|
||||
writeLine($fh, "-- ----------------------------");
|
||||
writeLine($fh, "DROP TRIGGER IF EXISTS `{$triggerName}`;");
|
||||
if (!str_ends_with($createTrig, ';')) $createTrig .= ';';
|
||||
writeLine($fh, $createTrig);
|
||||
writeLine($fh, "");
|
||||
}
|
||||
}
|
||||
$tRes->close();
|
||||
}
|
||||
|
||||
// Postamble
|
||||
writeLine($fh, "SET FOREIGN_KEY_CHECKS = 1;");
|
||||
writeLine($fh, "SET UNIQUE_CHECKS = 1;");
|
||||
writeLine($fh, "COMMIT;");
|
||||
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip a folder to $zipFilePath, skipping symlinks and dot-entries.
|
||||
*/
|
||||
function zipFolderStrict(string $folderPath, string $zipFilePath): void {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFilePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
error_log("Failed to open zip file: $zipFilePath");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Cannot open zip archive.");
|
||||
}
|
||||
|
||||
$folderReal = realpath($folderPath);
|
||||
if (!$folderReal || !is_dir($folderReal)) {
|
||||
// Create an empty archive if uploads folder doesn't exist yet
|
||||
$zip->close();
|
||||
return;
|
||||
}
|
||||
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($folderReal, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
/** @var SplFileInfo $file */
|
||||
if ($file->isDir()) continue;
|
||||
if ($file->isLink()) continue; // skip symlinks
|
||||
$filePath = $file->getRealPath();
|
||||
if ($filePath === false) continue;
|
||||
|
||||
// ensure path is inside the folder boundary
|
||||
if (strpos($filePath, $folderReal . DIRECTORY_SEPARATOR) !== 0 && $filePath !== $folderReal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($filePath, strlen($folderReal) + 1);
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
if (isset($_GET['download_backup'])) {
|
||||
if (isset($_GET['queue_backup'])) {
|
||||
|
||||
validateCSRFToken();
|
||||
|
||||
$timestamp = date('YmdHis');
|
||||
$baseName = "itflow_{$timestamp}";
|
||||
$downloadName = $baseName . ".zip";
|
||||
$type = strtolower(trim($_GET['queue_backup']));
|
||||
|
||||
// === Scoped cleanup of temp files ===
|
||||
$cleanupFiles = [];
|
||||
$registerTempFileForCleanup = function ($file) use (&$cleanupFiles) {
|
||||
$cleanupFiles[] = $file;
|
||||
};
|
||||
register_shutdown_function(function () use (&$cleanupFiles) {
|
||||
foreach ($cleanupFiles as $file) {
|
||||
if (is_file($file)) { @unlink($file); }
|
||||
}
|
||||
});
|
||||
$error = null;
|
||||
$backup_id = backupQueue($mysqli, $type, $session_name ?? 'Unknown User', $error);
|
||||
|
||||
// === Create temp files ===
|
||||
$sqlFile = tempnam(sys_get_temp_dir(), $baseName . "_sql_");
|
||||
$uploadsZip = tempnam(sys_get_temp_dir(), $baseName . "_uploads_");
|
||||
$versionFile = tempnam(sys_get_temp_dir(), $baseName . "_version_");
|
||||
$finalZip = tempnam(sys_get_temp_dir(), $baseName . "_backup_");
|
||||
|
||||
foreach ([$sqlFile, $uploadsZip, $versionFile, $finalZip] as $f) {
|
||||
$registerTempFileForCleanup($f);
|
||||
@chmod($f, 0600);
|
||||
if ($backup_id > 0) {
|
||||
logAudit("Backup", "Queue", ($session_name ?? 'Unknown User') . " queued a " . backupTypeLabel($type));
|
||||
flashAlert(backupTypeLabel($type) . " queued - it will start within a minute and you will be notified when it is ready.");
|
||||
} else {
|
||||
flashAlert($error ?? "Could not queue the backup.", 'error');
|
||||
}
|
||||
|
||||
// === Generate SQL Dump (streaming) ===
|
||||
dumpDatabase($mysqli, $sqlFile);
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
// === Zip the uploads folder (strict) ===
|
||||
zipFolderStrict("../uploads", $uploadsZip);
|
||||
if (isset($_GET['delete_backup'])) {
|
||||
|
||||
// === Gather metadata & checksums ===
|
||||
$commitHash = (function_exists('shell_exec') ? trim(shell_exec('git log -1 --format=%H 2>/dev/null')) : '') ?: 'N/A';
|
||||
$gitBranch = (function_exists('shell_exec') ? trim(shell_exec('git rev-parse --abbrev-ref HEAD 2>/dev/null')) : '') ?: 'N/A';
|
||||
validateCSRFToken();
|
||||
|
||||
$dbSha = hash_file('sha256', $sqlFile) ?: 'N/A';
|
||||
$upSha = hash_file('sha256', $uploadsZip) ?: 'N/A';
|
||||
$backup_id = intval($_GET['delete_backup']);
|
||||
|
||||
$versionContent = "ITFlow Backup Metadata\n";
|
||||
$versionContent .= "-----------------------------\n";
|
||||
$versionContent .= "Generated: " . date('Y-m-d H:i:s') . "\n";
|
||||
$versionContent .= "Backup File: " . $downloadName . "\n";
|
||||
$versionContent .= "Generated By: " . ($session_name ?? 'Unknown User') . "\n";
|
||||
$versionContent .= "Host: " . gethostname() . "\n";
|
||||
$versionContent .= "Git Branch: $gitBranch\n";
|
||||
$versionContent .= "Git Commit: $commitHash\n";
|
||||
$versionContent .= "ITFlow Version: " . (defined('APP_VERSION') ? APP_VERSION : 'Unknown') . "\n";
|
||||
$versionContent .= "Database Version: " . (defined('CURRENT_DATABASE_VERSION') ? CURRENT_DATABASE_VERSION : 'Unknown') . "\n";
|
||||
$versionContent .= "Checksums (SHA256):\n";
|
||||
$versionContent .= " db.sql: $dbSha\n";
|
||||
$versionContent .= " uploads.zip: $upSha\n";
|
||||
$sql = mysqli_query($mysqli, "SELECT backup_file_name, backup_type FROM backups WHERE backup_id = $backup_id");
|
||||
|
||||
file_put_contents($versionFile, $versionContent);
|
||||
@chmod($versionFile, 0600);
|
||||
|
||||
// === Build final ZIP ===
|
||||
$final = new ZipArchive();
|
||||
if ($final->open($finalZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== TRUE) {
|
||||
error_log("Failed to create final zip: $finalZip");
|
||||
http_response_code(500);
|
||||
exit("Internal Server Error: Unable to create backup archive.");
|
||||
if (mysqli_num_rows($sql) === 1) {
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
backupDeleteById($mysqli, $backup_id);
|
||||
logAudit("Backup", "Delete", ($session_name ?? 'Unknown User') . " deleted backup " . escapeSql($row['backup_file_name']));
|
||||
flashAlert("Backup deleted.");
|
||||
} else {
|
||||
flashAlert("Backup not found.", 'error');
|
||||
}
|
||||
$final->addFile($sqlFile, "db.sql");
|
||||
$final->addFile($uploadsZip, "uploads.zip");
|
||||
$final->addFile($versionFile, "version.txt");
|
||||
$final->close();
|
||||
|
||||
@chmod($finalZip, 0600);
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
// === Serve final ZIP with a stable filename ===
|
||||
header('Content-Type: application/zip');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
|
||||
header('Content-Length: ' . filesize($finalZip));
|
||||
header('Pragma: public');
|
||||
header('Expires: 0');
|
||||
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
|
||||
header('Content-Transfer-Encoding: binary');
|
||||
if (isset($_POST['edit_backup_settings'])) {
|
||||
|
||||
// Push file
|
||||
flush();
|
||||
$fp = fopen($finalZip, 'rb');
|
||||
fpassthru($fp);
|
||||
fclose($fp);
|
||||
validateCSRFToken();
|
||||
|
||||
// Log + UX
|
||||
logAudit("System", "Backup Download", ($session_name ?? 'Unknown User') . " downloaded full backup.");
|
||||
flashAlert("Full backup downloaded.");
|
||||
exit;
|
||||
$retention_days = intval($_POST['config_backup_retention_days']);
|
||||
$retention_count = intval($_POST['config_backup_retention_count']);
|
||||
$cron_type = escapeSql($_POST['config_backup_cron_type']);
|
||||
|
||||
if ($retention_days < 0) { $retention_days = 0; }
|
||||
if ($retention_count < 1) { $retention_count = 1; }
|
||||
|
||||
// The scheduled job runs without a session, so it can only produce the unattended types
|
||||
if (!in_array($cron_type, backupUnattendedTypes(), true)) {
|
||||
$cron_type = BACKUP_TYPE_FULL;
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE settings SET config_backup_retention_days = $retention_days, config_backup_retention_count = $retention_count, config_backup_cron_type = '$cron_type' WHERE company_id = 1");
|
||||
|
||||
logAudit("Backup", "Edit", ($session_name ?? 'Unknown User') . " updated the backup settings");
|
||||
flashAlert("Backup settings saved.");
|
||||
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
if (isset($_POST['backup_master_key'])) {
|
||||
@@ -306,26 +78,36 @@ if (isset($_POST['backup_master_key'])) {
|
||||
|
||||
$password = $_POST['password'];
|
||||
|
||||
$sql = mysqli_query($mysqli, "SELECT * FROM users WHERE user_id = $session_user_id");
|
||||
$sql = mysqli_query($mysqli, "SELECT user_password, user_specific_encryption_ciphertext FROM users WHERE user_id = $session_user_id");
|
||||
$row = mysqli_fetch_assoc($sql);
|
||||
|
||||
if (password_verify($password, $row['user_password'])) {
|
||||
$site_encryption_master_key = decryptUserSpecificKey($row['user_specific_encryption_ciphertext'], $password);
|
||||
|
||||
logAudit("Master Key", "Download", "$session_name retrieved the master encryption key");
|
||||
|
||||
appNotify("Master Key", "$session_name retrieved the master encryption key");
|
||||
|
||||
echo "==============================";
|
||||
echo "<br>Master encryption key:<br>";
|
||||
echo "<b>$site_encryption_master_key</b>";
|
||||
echo "<br>==============================";
|
||||
|
||||
} else {
|
||||
logAudit("Master Key", "Download", "$session_name attempted to retrieve the master encryption key but failed");
|
||||
|
||||
if (!$row || !password_verify($password, $row['user_password'])) {
|
||||
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " attempted to retrieve the master encryption key but failed");
|
||||
flashAlert("Incorrect password.", 'error');
|
||||
|
||||
redirect();
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
$site_encryption_master_key = decryptUserSpecificKey($row['user_specific_encryption_ciphertext'], $password);
|
||||
|
||||
if (empty($site_encryption_master_key)) {
|
||||
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " could not unwrap the master encryption key");
|
||||
flashAlert("Your password is correct but the master key could not be unwrapped from your account.", 'error');
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
logAudit("Master Key", "Download", ($session_name ?? 'Unknown User') . " retrieved the master encryption key");
|
||||
appNotify("Master Key", ($session_name ?? 'Unknown User') . " retrieved the master encryption key", "/admin/backup.php");
|
||||
|
||||
// Written as an encrypted archive too, so it can be filed with the other backups.
|
||||
// This is the one type cron can never produce - the key only exists inside a session.
|
||||
$error = null;
|
||||
backupCreate($mysqli, BACKUP_TYPE_MASTER_KEY, $session_name ?? 'Unknown User', 'Manual', $error, ['master_key' => $site_encryption_master_key]);
|
||||
|
||||
$_SESSION['backup_master_key_reveal'] = $site_encryption_master_key;
|
||||
|
||||
if ($error) {
|
||||
flashAlert("Master key shown below, but the encrypted copy could not be written: $error", 'error');
|
||||
}
|
||||
|
||||
redirect("backup.php");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user