Files
itflow/admin/backup.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 = '&lt;32 random characters&gt;';</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 &gt; 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 &gt; 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 &amp; 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 (per type)</label>
<input type="number" class="form-control" name="config_backup_retention_count" min="1" value="<?= intval($config_backup_retention_count) ?>">
<small class="text-muted">Counted separately for each type. The newest of each 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 &gt; 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>&times;</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";