mirror of
https://github.com/itflow-org/itflow
synced 2026-08-04 22:57: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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,6 +20,9 @@ uploads/users/*
|
||||
!uploads/users/index.php
|
||||
uploads/tmp/*
|
||||
!uploads/tmp/index.php
|
||||
uploads/backups/*
|
||||
!uploads/backups/index.php
|
||||
!uploads/backups/.htaccess
|
||||
uploads/tickets/*
|
||||
!uploads/tickets/index.php
|
||||
uploads/ticket_templates/*
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -3,6 +3,23 @@
|
||||
This file documents all notable changes made to ITFlow.
|
||||
|
||||
## [26.08]
|
||||
|
||||
### Backups
|
||||
|
||||
Backups are now encrypted, catalogued, schedulable, and restorable from the command line.
|
||||
|
||||
- **Three types** — Full (database + uploads), Database Only, and Master Key. Every archive is an AES-256 encrypted zip.
|
||||
- **One encryption key per install**, generated on first use and stored in `config.php` — never in the database and never in the file name. It is shown in Settings > Backup. **Write it down: without it a backup cannot be restored.** Open archives with 7-Zip, WinZip, PeaZip or Keka — `unzip`, Windows Explorer and the macOS Archive Utility do not support AES.
|
||||
- **Backups are built by cron, not by your browser.** The button queues the work and the dispatcher picks it up within the minute, then notifies you. A dump of a real install takes longer than a web request is allowed to live, which is why the old Download Backup button timed out on large instances.
|
||||
- **Scheduled backups** are a new `backup` cron job, off by default. Turn it on in Settings > Cron. Retention (by age and by count) runs in the nightly job and never deletes the newest backup.
|
||||
- **Archives are stored outside the web-served path** under `uploads/backups/` with a deny-all rule, and downloaded through an admin-only handler. Set `$config_backup_path` in `config.php` to keep them off the web root entirely.
|
||||
- **Restore from the command line** with `php scripts/restore_cli.php --file=/path/to/backup.zip`. This is the only restore path with no size limit — the setup wizard's restore is capped by PHP's upload limits, and a full backup is usually larger. Use `--inspect` to check an archive without changing anything.
|
||||
- **Restores validate before they destroy.** The key is checked and the archive unpacked before any table is dropped, and the current database is dumped first and put back automatically if the import fails.
|
||||
|
||||
### Security
|
||||
|
||||
- **The setup wizard's restore step is now closed on any install that has users**, whatever `config.php` says. Previously `$config_enable_setup` defaulted to enabled when the flag was missing from `config.php` — and the flag is only written at the very end of a successful install, so an install abandoned partway (or one where that final write failed) left an unauthenticated endpoint that would drop every table, import an attacker-supplied archive, and overwrite `uploads/` including the `.htaccess` that stops PHP executing there. Restoring over a live install is now done from the command line. This affects 26.07 and earlier.
|
||||
- A restore no longer takes ITFlow's `uploads/.htaccess` from the archive — the guards are rewritten afterwards regardless of what the backup contained, so restoring a backup taken before those guards existed no longer removes them.
|
||||
|
||||
### Upgrading to 26.08
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ There is no `composer install` or `npm install` step. All third-party libraries
|
||||
| `js/`, `css/` (root) | Shared front-end assets (portals also have their own). |
|
||||
| `libs/` | Vendored third-party libraries. Never edit these; update them wholesale. |
|
||||
| `setup/` | First-run installer. |
|
||||
| `scripts/` | Helper/utility scripts. |
|
||||
| `scripts/` | Helper/utility scripts — `setup_cli.php`, `update_cli.php`, `restore_cli.php`. CLI only; the directory denies web access. |
|
||||
|
||||
Rule of thumb: **root-level `includes/`, `post/`, `modals/`, `js/`, `css/` are shared code; everything inside a portal directory is scoped to that portal.**
|
||||
|
||||
@@ -100,6 +100,25 @@ Because the jobs share one PHP process, job code has three rules:
|
||||
3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Settings > Cron and in the dispatcher.
|
||||
4. **Set what you read.** One global scope and one set of `require_once` includes are shared across the cycle — a job's own `require_once "../config.php"` is a no-op if an earlier job already loaded it, and any variable an earlier job left behind is still there. Do not rely on the state a fresh process would have given you.
|
||||
|
||||
A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Settings > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night.
|
||||
|
||||
## Backups
|
||||
|
||||
`functions/backup.php` is the whole engine, and all three entry points go through it: Settings > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database.
|
||||
|
||||
Archives are AES-256 encrypted zips. The key is one value per install, generated on first use and appended to `config.php` — **never** the database and **never** the file name. That is the point: a backup that leaks cannot be opened with anything the backup itself contains, and a URL or an access log never carries the key. The 32 random characters in the file name are an unguessable path component, nothing more. Note that `unzip`, Windows Explorer and the macOS Archive Utility cannot read AES zips; 7-Zip, WinZip, PeaZip and Keka can.
|
||||
|
||||
The web tier never builds an archive in the request. It writes a `Pending` row and `cron/backup.php` does the work, because a dump of a real install outlives `request_terminate_timeout` and `set_time_limit()` does not help. Same reasoning as Run Now.
|
||||
|
||||
Two rules for anything touching restore:
|
||||
|
||||
1. **Validate before you destroy.** The key is checked and the archive unpacked before a single table is dropped, and the live database is dumped to a rollback file first. If the import fails the rollback goes back in. `mysqli` throws rather than returning false under PHP 8.1's default report mode, so every statement in the import path is wrapped — an uncaught throw there leaves an install with no database at all.
|
||||
2. **The archive does not get to decide what our guards say.** A restore wipes `uploads/`, and an archive is allowed to contain a `.htaccess`. `backupAssertUploadsGuards()` rewrites ours afterwards unconditionally, and the backup storage directory is preserved through the wipe so a restore cannot destroy every other archive on the box.
|
||||
|
||||
Retention lives in `nightly_tasks.php`, never in the backup job, so a failed backup cannot delete the archive it was supposed to replace. It never removes the newest complete backup, and an archive on disk with no row is **adopted** rather than deleted — after a restore the `backups` table is the old one, so everything made since looks unknown.
|
||||
|
||||
Setup's restore step closes itself once the `users` table has rows, whatever `config.php` says. It used to default `$config_enable_setup` to `1` when the flag was absent, which fails the wrong way: the flag is only appended at the end of a successful install, so an install abandoned in between left an unauthenticated endpoint that dropped every table, imported an arbitrary archive, and rewrote `uploads/` including the `.htaccess` that stops PHP running there.
|
||||
|
||||
Every script in `cron/` still runs standalone (`php cron/mail_queue.php`) and still takes its own lock when it does, so anything can be run by hand for testing.
|
||||
|
||||
---
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
70
cron/backup.php
Normal file
70
cron/backup.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
// Set working directory to the directory this cron script lives at.
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
// Ensure we're running from command line
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
// Prevent overlapping runs of this script
|
||||
$cron_lock_script = __FILE__;
|
||||
require_once "includes/cron_lock.php";
|
||||
|
||||
require_once "../config.php";
|
||||
|
||||
// Set Timezone
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
|
||||
$sql_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE settings.company_id = 1");
|
||||
$row = mysqli_fetch_assoc($sql_settings);
|
||||
|
||||
$config_enable_cron = intval($row['config_enable_cron']);
|
||||
$config_backup_cron_type = $row['config_backup_cron_type'] ?? 'full';
|
||||
|
||||
if ($config_enable_cron == 0) {
|
||||
cronJobStop("Cron: is not enabled\n");
|
||||
}
|
||||
|
||||
/*
|
||||
* Anything an administrator started from Settings > Backup is built first. Those are
|
||||
* explicit requests and somebody is waiting on the notification.
|
||||
*/
|
||||
$queued = backupRunQueued($mysqli);
|
||||
|
||||
if ($queued > 0) {
|
||||
echo "Built $queued queued backup(s)\n";
|
||||
}
|
||||
|
||||
/*
|
||||
* Then the scheduled one. The dispatcher only calls this script when the schedule says so,
|
||||
* so reaching here means a scheduled backup is due.
|
||||
*
|
||||
* Skipped if a backup of the same type already completed today - the day-match trap from
|
||||
* CONTRIBUTING's cron rules. A second dispatch in the same day (a manual Run Now, a catch-up
|
||||
* after downtime) must not produce a second scheduled archive.
|
||||
*/
|
||||
$type = in_array($config_backup_cron_type, backupUnattendedTypes(), true) ? $config_backup_cron_type : BACKUP_TYPE_FULL;
|
||||
$type_esc = escapeSql($type);
|
||||
|
||||
$already = mysqli_num_rows(mysqli_query($mysqli, "SELECT backup_id FROM backups WHERE backup_source = 'Cron' AND backup_type = '$type_esc' AND backup_status = 'Complete' AND backup_completed_at >= CURDATE()"));
|
||||
|
||||
if ($already > 0) {
|
||||
echo "Scheduled backup already ran today\n";
|
||||
} else {
|
||||
$error = null;
|
||||
$backup_id = backupCreate($mysqli, $type, 'Cron', 'Cron', $error);
|
||||
|
||||
if ($backup_id > 0) {
|
||||
echo "Scheduled " . backupTypeLabel($type) . " complete\n";
|
||||
appNotify("Backup", "Scheduled " . backupTypeLabel($type) . " is ready to download", "/admin/backup.php");
|
||||
logAudit("Backup", "Create", "Scheduled " . backupTypeLabel($type) . " completed");
|
||||
} else {
|
||||
echo "Scheduled backup FAILED: $error\n";
|
||||
appNotify("Backup", "Scheduled backup failed: $error", "/admin/backup.php");
|
||||
logAudit("Backup", "Create", "Scheduled backup failed: $error");
|
||||
logApp("Backup", "error", "Scheduled backup failed: $error");
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,11 @@ function cronJobClaim($mysqli, array $job): bool
|
||||
$default_interval = intval($job['interval_minutes'] ?? 1);
|
||||
$default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL';
|
||||
|
||||
$default_enabled = isset($job['enabled']) ? intval($job['enabled']) : 1;
|
||||
|
||||
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET
|
||||
cron_job_name = '$name',
|
||||
cron_job_enabled = $default_enabled,
|
||||
cron_job_schedule = '$default_schedule',
|
||||
cron_job_interval_minutes = $default_interval,
|
||||
cron_job_daily_at = $default_daily_at");
|
||||
@@ -156,12 +159,24 @@ function cronJobFinished($mysqli, string $job_name, string $status, ?float $dura
|
||||
$error_sql = ", cron_job_last_error = '$error_text', cron_job_last_error_at = '$finished_at'";
|
||||
}
|
||||
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_finished_at = '$finished_at',
|
||||
cron_job_last_status = '$status',
|
||||
cron_job_last_duration = $duration_sql
|
||||
$error_sql
|
||||
WHERE cron_job_name = '$name'");
|
||||
// This is the failure path. A job that killed the database connection - a long backup
|
||||
// whose idle connection was closed, a server restart mid-cycle - must not have its
|
||||
// bookkeeping throw on top, or an uncaught mysqli_sql_exception ends the dispatch and
|
||||
// no record of the original failure survives anywhere.
|
||||
if (function_exists('backupDbEnsure')) {
|
||||
$mysqli = backupDbEnsure($mysqli);
|
||||
}
|
||||
|
||||
try {
|
||||
mysqli_query($mysqli, "UPDATE cron_jobs SET
|
||||
cron_job_last_finished_at = '$finished_at',
|
||||
cron_job_last_status = '$status',
|
||||
cron_job_last_duration = $duration_sql
|
||||
$error_sql
|
||||
WHERE cron_job_name = '$name'");
|
||||
} catch (Throwable $e) {
|
||||
echo "Cron: could not record the outcome of '$job_name' - " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell
|
||||
@@ -218,7 +233,12 @@ foreach (cronJobRegistry() as $cron_dispatch_job) {
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason", microtime(true) - $cron_dispatch_started);
|
||||
} catch (Throwable $e) {
|
||||
// One job throwing is not a reason to skip the rest of the cycle
|
||||
logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
|
||||
echo "Cron: job '{$cron_dispatch_job['name']}' failed - " . $e->getMessage() . "\n";
|
||||
try {
|
||||
logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
|
||||
} catch (Throwable $log_e) {
|
||||
// Logging the failure must never become a second, fatal failure
|
||||
}
|
||||
cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed', microtime(true) - $cron_dispatch_started, $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,11 @@ mysqli_query($mysqli, "DELETE FROM email_queue WHERE email_queued_at < CURDATE()
|
||||
// Clean-up old remember me tokens
|
||||
mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_created_at < CURDATE() - INTERVAL $config_login_remember_me_expire DAY");
|
||||
|
||||
// Cleanup old backups, and reconcile rows whose file is gone against files with no row.
|
||||
// Retention lives here rather than in cron/backup.php so a failed backup run can never
|
||||
// delete the archive it was supposed to replace.
|
||||
backupRunRetention($mysqli);
|
||||
|
||||
// Cleanup old audit logs
|
||||
mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
|
||||
|
||||
|
||||
30
db.sql
30
db.sql
@@ -351,6 +351,31 @@ CREATE TABLE `auth_logs` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `backups`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `backups`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `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;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `budget`
|
||||
--
|
||||
@@ -2278,6 +2303,9 @@ CREATE TABLE `settings` (
|
||||
`config_ticket_ordering` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`config_ticket_moving_columns` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`config_cron_last_dispatch_at` datetime DEFAULT NULL,
|
||||
`config_backup_retention_days` int(11) NOT NULL DEFAULT 30,
|
||||
`config_backup_retention_count` int(11) NOT NULL DEFAULT 5,
|
||||
`config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full',
|
||||
PRIMARY KEY (`company_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
@@ -3122,4 +3150,4 @@ CREATE TABLE `vendors` (
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2026-07-31 14:15:12
|
||||
-- Dump completed on 2026-07-31 16:18:11
|
||||
|
||||
@@ -25,3 +25,4 @@ require_once __DIR__ . '/functions/payments.php';
|
||||
require_once __DIR__ . '/functions/sla.php';
|
||||
require_once __DIR__ . '/functions/export.php';
|
||||
require_once __DIR__ . '/functions/calendar.php';
|
||||
require_once __DIR__ . '/functions/backup.php';
|
||||
|
||||
1418
functions/backup.php
Normal file
1418
functions/backup.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,10 @@
|
||||
* That shared use is why this sits here rather than in cron/includes/ with the lock, which
|
||||
* only cron loads: the admin pages would otherwise be reaching into the cron directory.
|
||||
*
|
||||
* 'enabled' => 0 ships a job switched off - the row is seeded disabled and stays that way
|
||||
* until somebody turns it on in Settings > Cron. Used for work an install should opt into
|
||||
* rather than inherit from an upgrade, like the backup job filling a disk overnight.
|
||||
*
|
||||
* 'interval_safe' => false marks a job whose work repeats if the day repeats - nightly's
|
||||
* late fees and overdue reminders fire again on a second run of the same day. Settings >
|
||||
* Cron only offers the daily schedule for such a job, and the dispatcher refuses to run
|
||||
@@ -73,6 +77,16 @@ function cronJobRegistry(): array
|
||||
'daily_at' => '03:00',
|
||||
'interval_safe' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'backup',
|
||||
'label' => 'Backup',
|
||||
'script' => 'backup.php',
|
||||
'description' => 'Builds the scheduled backup and anything queued from Settings > Backup. Off by default.',
|
||||
'schedule' => 'Daily',
|
||||
'daily_at' => '02:00',
|
||||
'enabled' => 0,
|
||||
'interval_safe' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'certificate_refresher',
|
||||
'label' => 'Certificate Refresher',
|
||||
|
||||
136
scripts/restore_cli.php
Normal file
136
scripts/restore_cli.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* ITFlow - Command line restore
|
||||
*
|
||||
* Replaces the database (and the uploads folder, for a full backup) with the contents of
|
||||
* an encrypted ITFlow backup archive.
|
||||
*
|
||||
* This is the only restore path with no size limit. The setup wizard's restore has to
|
||||
* receive the archive through a browser upload, which PHP caps at upload_max_filesize /
|
||||
* post_max_size - a real full backup is usually larger than both.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/restore_cli.php --file=/path/to/itflow_20260731-020000_full_XXXX.zip
|
||||
*
|
||||
* Options:
|
||||
* --file=PATH The backup archive. Required.
|
||||
* --key=KEY Encryption key. Defaults to $config_backup_key from config.php.
|
||||
* --inspect Show what is in the archive and exit without changing anything.
|
||||
* --yes Skip the confirmation prompt (for unattended use).
|
||||
*/
|
||||
|
||||
chdir(dirname(__FILE__));
|
||||
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
die("This script must be run from the command line.\n");
|
||||
}
|
||||
|
||||
if (!file_exists("../config.php")) {
|
||||
fwrite(STDERR, "config.php not found.\n\n");
|
||||
fwrite(STDERR, "A restore needs a working database connection, so ITFlow has to be installed first.\n");
|
||||
fwrite(STDERR, "Run the setup wizard (or scripts/setup_cli.php) to create config.php, then run this again.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require_once "../config.php";
|
||||
require_once "../includes/inc_set_timezone.php";
|
||||
require_once "../functions.php";
|
||||
|
||||
$options = getopt("", ["file:", "key:", "inspect", "yes", "help"]);
|
||||
|
||||
if (isset($options['help']) || empty($options['file'])) {
|
||||
echo "Usage: php scripts/restore_cli.php --file=/path/to/backup.zip [--key=KEY] [--inspect] [--yes]\n\n";
|
||||
echo " --file=PATH The backup archive to restore. Required.\n";
|
||||
echo " --key=KEY Encryption key. Defaults to \$config_backup_key from config.php.\n";
|
||||
echo " --inspect Show what is in the archive and exit without changing anything.\n";
|
||||
echo " --yes Skip the confirmation prompt.\n";
|
||||
exit(isset($options['help']) ? 0 : 1);
|
||||
}
|
||||
|
||||
$file = $options['file'];
|
||||
|
||||
if (!is_file($file)) {
|
||||
fwrite(STDERR, "Backup file not found: $file\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// The key from config.php is the right one for a backup made by this install. A backup
|
||||
// carried over from another server needs that server's key passed in.
|
||||
$key = $options['key'] ?? ($config_backup_key ?? '');
|
||||
|
||||
if ($key === '') {
|
||||
fwrite(STDERR, "No encryption key.\n\n");
|
||||
fwrite(STDERR, "config.php has no \$config_backup_key, so pass the key from the install that made\n");
|
||||
fwrite(STDERR, "this backup with --key=... It is shown in Settings > Backup on that install.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$error = null;
|
||||
$meta = backupInspectArchive($file, $key, $error);
|
||||
|
||||
if ($meta === false) {
|
||||
fwrite(STDERR, "Cannot read this backup: $error\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Backup archive: " . basename($file) . "\n";
|
||||
echo "Size: " . backupFormatBytes(filesize($file)) . "\n";
|
||||
echo "Type: " . backupTypeLabel($meta['type']) . "\n";
|
||||
echo "Taken: " . $meta['generated'] . "\n";
|
||||
echo "ITFlow version: " . $meta['app_version'] . "\n";
|
||||
echo "Database version:" . " " . $meta['database_version'] . "\n";
|
||||
echo "Contains: " . implode(", ", $meta['entries']) . "\n";
|
||||
|
||||
$current_db_version = backupCurrentDatabaseVersion($mysqli);
|
||||
echo "This install is at database version $current_db_version.\n";
|
||||
|
||||
if (isset($options['inspect'])) {
|
||||
echo "\n--inspect given, nothing was changed.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($meta['database_version'] !== 'Unknown' && version_compare($meta['database_version'], $current_db_version, '>')) {
|
||||
echo "\nWARNING: this backup is from a NEWER database version than the code in this directory.\n";
|
||||
echo "Update ITFlow to a matching version before restoring, or the app will error after the restore.\n";
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
echo "This will REPLACE the database";
|
||||
if (in_array('uploads.zip', $meta['entries'], true)) {
|
||||
echo " and everything in the uploads folder";
|
||||
}
|
||||
echo ".\n";
|
||||
echo "The current database is dumped first and put back automatically if the restore fails.\n";
|
||||
|
||||
if (!isset($options['yes'])) {
|
||||
echo "\nType 'restore' to continue: ";
|
||||
$answer = trim(fgets(STDIN) ?: '');
|
||||
if ($answer !== 'restore') {
|
||||
echo "Aborted, nothing was changed.\n";
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
$restore_error = null;
|
||||
$ok = backupRestoreArchive($mysqli, $file, $key, $restore_error, function ($message) {
|
||||
echo " $message\n";
|
||||
});
|
||||
|
||||
if (!$ok) {
|
||||
fwrite(STDERR, "\nRestore failed: $restore_error\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
logAudit("Backup", "Restore", "Restored from " . escapeSql(basename($file)) . " via the command line");
|
||||
|
||||
echo "\nDone.\n";
|
||||
echo "\nNext steps:\n";
|
||||
echo " 1. Log in with the credentials that were in use when the backup was taken.\n";
|
||||
echo " 2. If the backup is older than the code in this directory, finish the database update:\n";
|
||||
echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n";
|
||||
echo " 3. Check Settings > Cron - the schedule came back with the database.\n";
|
||||
|
||||
exit(0);
|
||||
355
setup/index.php
355
setup/index.php
@@ -8,26 +8,43 @@ if (file_exists("../config.php")) {
|
||||
include "../functions.php"; // Global Functions
|
||||
include "../includes/database_version.php";
|
||||
|
||||
if (!isset($config_enable_setup)) {
|
||||
$config_enable_setup = 1;
|
||||
}
|
||||
|
||||
if ($config_enable_setup == 0) {
|
||||
header("Location: /login.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
$mysqli_available = isset($mysqli) && $mysqli instanceof mysqli;
|
||||
$can_show_restore = false;
|
||||
$should_skip_to_user = false;
|
||||
|
||||
/*
|
||||
* An install with users in it is a live install, and setup is closed on one whatever
|
||||
* config.php says.
|
||||
*
|
||||
* This used to default $config_enable_setup to 1 when the flag was absent, which fails the
|
||||
* wrong way: config.php is written when the database step completes but the flag is only
|
||||
* appended at the very end of a successful run, so an install abandoned in between - or one
|
||||
* where that final append failed - left the restore below reachable with no authentication
|
||||
* at all. That endpoint drops every table and imports whatever archive it is handed, and it
|
||||
* rewrites the uploads directory, including the .htaccess that stops PHP running there.
|
||||
*/
|
||||
$install_is_live = false;
|
||||
|
||||
if (file_exists("../config.php") && $mysqli_available) {
|
||||
$table_result = mysqli_query($mysqli, "SHOW TABLES LIKE 'users'");
|
||||
if ($table_result && mysqli_num_rows($table_result) > 0) {
|
||||
$can_show_restore = true;
|
||||
$should_skip_to_user = true;
|
||||
} else {
|
||||
// If DB exists but doesn't have user table yet, maybe still allow restore
|
||||
|
||||
$user_count_result = mysqli_query($mysqli, "SELECT COUNT(*) AS user_count FROM users");
|
||||
if ($user_count_result) {
|
||||
$user_count_row = mysqli_fetch_assoc($user_count_result);
|
||||
if (intval($user_count_row['user_count']) > 0) {
|
||||
$install_is_live = true;
|
||||
}
|
||||
} else {
|
||||
// Cannot prove the install is empty, so treat it as live
|
||||
$install_is_live = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore needs a database connection and an empty install. A populated one restores
|
||||
// from the command line instead - scripts/restore_cli.php.
|
||||
if (!$install_is_live) {
|
||||
$all_tables = mysqli_query($mysqli, "SHOW TABLES");
|
||||
if ($all_tables && mysqli_num_rows($all_tables) > 0) {
|
||||
$can_show_restore = true;
|
||||
@@ -35,6 +52,15 @@ if (file_exists("../config.php") && $mysqli_available) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($config_enable_setup)) {
|
||||
$config_enable_setup = $install_is_live ? 0 : 1;
|
||||
}
|
||||
|
||||
if ($config_enable_setup == 0 || $install_is_live) {
|
||||
header("Location: /login.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
include_once "../includes/settings_localization_array.php";
|
||||
$errorLog = ini_get('error_log') ?: "Debian/Ubuntu default is usually /var/log/apache2/error.log";
|
||||
|
||||
@@ -126,239 +152,93 @@ if (isset($_POST['add_database'])) {
|
||||
|
||||
if (isset($_POST['restore'])) {
|
||||
|
||||
// ---------- Long-running guards ----------
|
||||
@set_time_limit(0);
|
||||
if (function_exists('ini_set')) { @ini_set('memory_limit', '1024M'); }
|
||||
|
||||
// ---------- Minimal helpers (scoped) ----------
|
||||
if (!function_exists('deleteDir')) {
|
||||
function deleteDir($dir) {
|
||||
if (!is_dir($dir)) return;
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
// Belt and braces: the page-level gate above already sends a live install to the login
|
||||
// page, but this handler is the destructive one, so it re-checks rather than trusting
|
||||
// that it was only reached through the form.
|
||||
if ($install_is_live || !$can_show_restore) {
|
||||
$_SESSION['alert_message'] = "This install already has users. Restore over it from the command line instead: php scripts/restore_cli.php --file=/path/to/backup.zip";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!function_exists('importSqlFile')) {
|
||||
/**
|
||||
* Import a SQL file via mysqli, supports DELIMITER and multi statements.
|
||||
*/
|
||||
function importSqlFile(mysqli $mysqli, string $path): void {
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new RuntimeException("SQL file not found or unreadable: $path");
|
||||
}
|
||||
$fh = fopen($path, 'r');
|
||||
if (!$fh) throw new RuntimeException("Failed to open SQL file");
|
||||
// An upload larger than post_max_size arrives with $_FILES and $_POST both empty, so
|
||||
// PHP cannot tell us which field failed - it is worth naming, because a real full
|
||||
// backup is usually bigger than the limit and the old message just said the upload
|
||||
// failed.
|
||||
$max_upload_bytes = backupMaxUploadBytes();
|
||||
$content_length = intval($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
|
||||
$delimiter = ';';
|
||||
$statement = '';
|
||||
|
||||
while (($line = fgets($fh)) !== false) {
|
||||
$trim = trim($line);
|
||||
|
||||
// Skip comments/empty
|
||||
if ($trim === '' || str_starts_with($trim, '--') || str_starts_with($trim, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle DELIMITER changes
|
||||
if (preg_match('/^DELIMITER\s+(.+)$/i', $trim, $m)) {
|
||||
$delimiter = $m[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
$statement .= $line;
|
||||
|
||||
// End of statement?
|
||||
if (substr(rtrim($statement), -strlen($delimiter)) === $delimiter) {
|
||||
$sql = substr($statement, 0, -strlen($delimiter));
|
||||
if ($mysqli->multi_query($sql) === false) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException("SQL error: " . $mysqli->error);
|
||||
}
|
||||
// Flush any result sets
|
||||
while ($mysqli->more_results() && $mysqli->next_result()) { /* discard */ }
|
||||
$statement = '';
|
||||
}
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
$too_large = false;
|
||||
if (!isset($_FILES['backup_zip']) && $content_length > 0 && $max_upload_bytes > 0 && $content_length > $max_upload_bytes) {
|
||||
$too_large = true;
|
||||
} elseif (isset($_FILES['backup_zip']) && in_array($_FILES['backup_zip']['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) {
|
||||
$too_large = true;
|
||||
}
|
||||
|
||||
if ($too_large) {
|
||||
$_SESSION['alert_message'] = "That backup is too large to upload through a browser (this server accepts up to "
|
||||
. backupFormatBytes($max_upload_bytes)
|
||||
. "). Restore it from the command line instead - there is no size limit there. Copy the backup onto this server and run: php "
|
||||
. dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- 1) Validate uploaded backup ----------
|
||||
if (!isset($_FILES['backup_zip']) || $_FILES['backup_zip']['error'] !== UPLOAD_ERR_OK) {
|
||||
die("No backup file uploaded or upload failed.");
|
||||
$_SESSION['alert_message'] = "No backup file was uploaded, or the upload failed.";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['backup_zip'];
|
||||
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if ($fileExt !== "zip") {
|
||||
die("Only .zip files are allowed.");
|
||||
if (strtolower(pathinfo($_FILES['backup_zip']['name'], PATHINFO_EXTENSION)) !== 'zip') {
|
||||
$_SESSION['alert_message'] = "Only .zip backup archives can be restored.";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------- 2) Save to secure temp ----------
|
||||
$tempZip = tempnam(sys_get_temp_dir(), "restore_");
|
||||
if (!move_uploaded_file($file["tmp_name"], $tempZip)) {
|
||||
die("Failed to save uploaded backup file.");
|
||||
}
|
||||
@chmod($tempZip, 0600);
|
||||
|
||||
$zip = new ZipArchive;
|
||||
if ($zip->open($tempZip) !== TRUE) {
|
||||
@unlink($tempZip);
|
||||
die("Failed to open backup zip file.");
|
||||
// The key belongs to the install that MADE the backup, which on a rebuilt server is not
|
||||
// this one, so it is asked for rather than read from config.php.
|
||||
$restore_key = trim($_POST['backup_key'] ?? '');
|
||||
if ($restore_key === '') {
|
||||
$restore_key = $config_backup_key ?? '';
|
||||
}
|
||||
|
||||
// ---------- 3) Guard & extract OUTER zip ----------
|
||||
$tempDir = sys_get_temp_dir() . "/restore_temp_" . uniqid("", true);
|
||||
if (!mkdir($tempDir, 0700, true)) {
|
||||
$zip->close();
|
||||
@unlink($tempZip);
|
||||
die("Failed to create temp directory.");
|
||||
if ($restore_key === '') {
|
||||
$_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Settings > Backup on the install that made this archive.";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Zip-slip guard (outer)
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false) continue;
|
||||
if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
|
||||
$zip->close();
|
||||
@unlink($tempZip);
|
||||
deleteDir($tempDir);
|
||||
die("Invalid file path in outer ZIP.");
|
||||
}
|
||||
$temp_zip = tempnam(sys_get_temp_dir(), "itflow_restore_upload_");
|
||||
if (!move_uploaded_file($_FILES['backup_zip']['tmp_name'], $temp_zip)) {
|
||||
@unlink($temp_zip);
|
||||
$_SESSION['alert_message'] = "Could not save the uploaded backup file.";
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
@chmod($temp_zip, 0600);
|
||||
|
||||
$restore_error = null;
|
||||
$restored = backupRestoreArchive($mysqli, $temp_zip, $restore_key, $restore_error);
|
||||
|
||||
@unlink($temp_zip);
|
||||
|
||||
if (!$restored) {
|
||||
$_SESSION['alert_message'] = $restore_error;
|
||||
header("Location: ?restore");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$zip->extractTo($tempDir)) {
|
||||
$zip->close();
|
||||
@unlink($tempZip);
|
||||
deleteDir($tempDir);
|
||||
die("Failed to extract backup contents.");
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@unlink($tempZip);
|
||||
|
||||
// ---------- 4) Restore SQL (via PHP, no CLI) ----------
|
||||
$sqlPath = "$tempDir/db.sql";
|
||||
if (file_exists($sqlPath)) {
|
||||
// Drop-all first (foreign key safe)
|
||||
mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 0");
|
||||
$tables = mysqli_query($mysqli, "SHOW TABLES");
|
||||
if ($tables) {
|
||||
while ($row = mysqli_fetch_row($tables)) {
|
||||
mysqli_query($mysqli, "DROP TABLE IF EXISTS `" . $row[0] . "`");
|
||||
}
|
||||
}
|
||||
mysqli_query($mysqli, "SET FOREIGN_KEY_CHECKS = 1");
|
||||
|
||||
try {
|
||||
importSqlFile($mysqli, $sqlPath);
|
||||
} catch (Throwable $e) {
|
||||
deleteDir($tempDir);
|
||||
die("SQL import failed: " . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8'));
|
||||
}
|
||||
// Close setup behind us. The gate above would now do this on its own because the
|
||||
// restored database has users, but the flag is what stops the wizard being reachable
|
||||
// at all.
|
||||
$config_path = __DIR__ . "/../config.php";
|
||||
if (@file_put_contents($config_path, "\n\$config_enable_setup = 0;\n\n", FILE_APPEND | LOCK_EX) === false) {
|
||||
$_SESSION['alert_message'] = "Backup restored, but config.php could not be updated - please set \$config_enable_setup = 0 in it by hand.";
|
||||
} else {
|
||||
deleteDir($tempDir);
|
||||
die("Missing db.sql in the backup archive.");
|
||||
$_SESSION['alert_message'] = "Backup restored. Log in with the credentials that were in use when the backup was taken.";
|
||||
}
|
||||
|
||||
// ---------- 5) Restore uploads directory ----------
|
||||
$uploadDir = rtrim(__DIR__ . "/../uploads", '/\\') . '/';
|
||||
$uploadsZip = "$tempDir/uploads.zip";
|
||||
|
||||
if (!file_exists($uploadsZip)) {
|
||||
deleteDir($tempDir);
|
||||
die("Missing uploads.zip in the backup archive.");
|
||||
}
|
||||
|
||||
$uploads = new ZipArchive;
|
||||
if ($uploads->open($uploadsZip) !== TRUE) {
|
||||
deleteDir($tempDir);
|
||||
die("Failed to open uploads.zip in backup.");
|
||||
}
|
||||
|
||||
// Zip-slip guard (inner)
|
||||
for ($i = 0; $i < $uploads->numFiles; $i++) {
|
||||
$name = $uploads->getNameIndex($i);
|
||||
if ($name === false) continue;
|
||||
if (strpos($name, '..') !== false || preg_match('#^(?:/|\\\\|[a-zA-Z]:[\\\\/])#', $name)) {
|
||||
$uploads->close();
|
||||
deleteDir($tempDir);
|
||||
die("Invalid file path in uploads.zip.");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure uploads dir exists then clean it
|
||||
if (!is_dir($uploadDir)) {
|
||||
if (!mkdir($uploadDir, 0750, true)) {
|
||||
$uploads->close();
|
||||
deleteDir($tempDir);
|
||||
die("Failed to create uploads directory.");
|
||||
}
|
||||
} else {
|
||||
foreach (new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
) as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract uploads.zip directly into /uploads (your original, working behavior)
|
||||
if (!$uploads->extractTo($uploadDir)) {
|
||||
$uploads->close();
|
||||
deleteDir($tempDir);
|
||||
die("Failed to extract uploads.zip into uploads directory.");
|
||||
}
|
||||
$uploads->close();
|
||||
|
||||
// Verify uploads isn’t empty
|
||||
$hasFiles = false;
|
||||
$fileCount = 0; $dirCount = 0;
|
||||
if (is_dir($uploadDir)) {
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($uploadDir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
foreach ($it as $node) {
|
||||
if ($node->isDir()) $dirCount++;
|
||||
else { $fileCount++; $hasFiles = true; }
|
||||
}
|
||||
}
|
||||
if (!$hasFiles) {
|
||||
deleteDir($tempDir);
|
||||
die("Uploads restore appears empty after extraction.");
|
||||
}
|
||||
|
||||
// ---------- 6) Optional: version info ----------
|
||||
$versionTxt = "$tempDir/version.txt";
|
||||
if (file_exists($versionTxt)) {
|
||||
$versionInfo = @file_get_contents($versionTxt);
|
||||
if ($versionInfo !== false) {
|
||||
logAudit("Backup Restore", "Version Info", $versionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 7) Cleanup temp ----------
|
||||
deleteDir($tempDir);
|
||||
|
||||
// ---------- 8) Finalize setup flag (append safely) ----------
|
||||
$configPath = __DIR__ . "/../config.php";
|
||||
$append = "\n\$config_enable_setup = 0;\n\n";
|
||||
if (!@file_put_contents($configPath, $append, FILE_APPEND | LOCK_EX)) {
|
||||
$_SESSION['alert_message'] = "Backup restored ($fileCount files, $dirCount folders), but couldn't update setup flag — please set \$config_enable_setup = 0 in config.php.";
|
||||
} else {
|
||||
$_SESSION['alert_message'] = "Full backup restored successfully ($fileCount files, $dirCount folders).";
|
||||
}
|
||||
|
||||
// ---------- 9) Done ----------
|
||||
header("Location: ../login.php");
|
||||
exit;
|
||||
}
|
||||
@@ -1264,10 +1144,27 @@ if (isset($_POST['add_telemetry'])) {
|
||||
<h3 class="card-title"><i class="fas fa-fw fa-database mr-2"></i>Restore from Backup</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php $setup_max_upload = backupMaxUploadBytes(); ?>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" autocomplete="off">
|
||||
<label>Restore ITFlow Backup (.zip)</label>
|
||||
<input type="file" name="backup_zip" accept=".zip" required>
|
||||
<p class="text-muted mt-2 mb-0"><small>Large restores may take several minutes. Do not close this page.</small></p>
|
||||
<div class="form-group">
|
||||
<label>ITFlow backup archive (.zip)</label>
|
||||
<input type="file" name="backup_zip" accept=".zip" class="form-control-file" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Backup encryption key</label>
|
||||
<input type="text" name="backup_key" class="form-control" placeholder="The key from the install that made this backup" autocomplete="off" required>
|
||||
<small class="text-muted">Shown in Settings > Backup on that install. The archive cannot be opened without it.</small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mb-0">
|
||||
<strong>This server accepts uploads up to <?= escapeHtml(backupFormatBytes($setup_max_upload)) ?>.</strong>
|
||||
A full backup is usually larger than that. If yours is, copy it onto the server and restore from the
|
||||
command line instead - there is no size limit there:
|
||||
<pre class="bg-dark text-white p-2 mt-2 mb-0"><?= escapeHtml("php " . dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip") ?></pre>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mt-2 mb-0"><small>The restore replaces the database and the uploads folder. Large restores take several minutes - do not close this page.</small></p>
|
||||
<hr>
|
||||
<button type="submit" name="restore" class="btn btn-primary text-bold">
|
||||
Restore Backup<i class="fas fa-fw fa-upload ml-2"></i>
|
||||
|
||||
3
uploads/backups/.htaccess
Normal file
3
uploads/backups/.htaccess
Normal file
@@ -0,0 +1,3 @@
|
||||
Require all denied
|
||||
Options -ExecCGI -Indexes
|
||||
php_flag engine off
|
||||
1
uploads/backups/index.php
Normal file
1
uploads/backups/index.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php // Silence is golden
|
||||
Reference in New Issue
Block a user