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.
293 lines
15 KiB
PHP
293 lines
15 KiB
PHP
<?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-cloud-upload-alt mr-2"></i>Create a Backup</h3>
|
|
</div>
|
|
<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-undo mr-2"></i>Restoring</h3>
|
|
</div>
|
|
<div class="card-body">
|
|
<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="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>
|
|
</div>
|
|
</div>
|
|
|
|
<?php
|
|
require_once "../includes/footer.php";
|