includes/backup.php
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| Backup helpers
|--------------------------------------------------------------------------
|
| Keeps rotating SQLite backups and restores the latest valid backup if the
| live database becomes corrupted.
|
| Directory paths come from core helpers through helper functions:
| - backupDir()
| - corruptDir()
| - liveDbFile()
| - logDir()
|
*/
/*
|--------------------------------------------------------------------------
| Original private key backup
|--------------------------------------------------------------------------
|
| Keep a permanent copy of the original installation key.
| The backup is created once and never overwritten.
|
| This file exists because somebody, someday, will delete:
|
| brivacia.key
|
| Please don't be that person.
|
*/
$keyFile = brivacia_key_path();
if (!brivacia_is_installed() || !is_file($keyFile)) {
return;
}
$backupKeyFile = backupDir() . '/brivacia.key.bak';
if (!is_file($backupKeyFile)) {
copy($keyFile, $backupKeyFile);
brivaciaLog(
'backup/private-key.log',
'saved original private key backup'
);
}
// Dear future 🤡: this backup 👆 is for you.
/*
|--------------------------------------------------------------------------
| Original referrers.json backup
|--------------------------------------------------------------------------
|
| Keep a permanent copy of the bundled referrers.json.
| This backup is created once and never overwritten.
|
*/
$referrersFile = dataDir() . '/referrers.json';
if (!is_file($referrersFile)) {
return;
}
$originalBackupFile = backupDir() . '/referrers.original.json.bak';
if (!is_file($originalBackupFile)) {
copy($referrersFile, $originalBackupFile);
brivaciaLog(
'backup/referrers-json.log',
'saved original referrers.json backup'
);
}
/*
|--------------------------------------------------------------------------
| Current referrers.json backup
|--------------------------------------------------------------------------
|
| Keep a backup of the current referrers.json.
| This backup may be overwritten to reflect recent changes.
|
*/
$currentBackupFile = backupDir() . '/referrers.json.bak';
copy($referrersFile, $currentBackupFile);
brivaciaLog(
'backup/referrers-json.log',
'updated current referrers.json backup'
);
/*
|--------------------------------------------------------------------------
| Current settings.json backup
|--------------------------------------------------------------------------
|
| Keep a recoverable copy of the latest settings.json.
| This backup may be overwritten when settings are updated.
|
*/
$settingsFile = dataDir() . '/settings.json';
$backupSettingsFile = backupDir() . '/settings.json.bak';
if (is_file($settingsFile)) {
copy($settingsFile, $backupSettingsFile);
brivaciaLog(
'backup/settings-json.log',
'saved current settings.json backup'
);
}
/*
|--------------------------------------------------------------------------
| Scheduled backup
|--------------------------------------------------------------------------
|
| Checks at most once every 10 minutes whether an hourly backup is needed.
| Six rotating slots are used to keep the storage footprint small.
|
*/
function maybebackupBrivaciaDb(): void {
$stamp = backupDir() . '/backup.lastcheck';
if (
is_file($stamp) &&
time() - filemtime($stamp) < 600
) {
return;
}
@touch($stamp);
$slot = (int)date('G') % 6;
$file = backupDir() . '/brivacia-hourly-' . $slot . '.sqlite';
if (
is_file($file) &&
time() - filemtime($file) < 3600
) {
return;
}
backupBrivaciaDb($file);
}
/*
|--------------------------------------------------------------------------
| Backup creation
|--------------------------------------------------------------------------
|
| Creates a clean SQLite snapshot using VACUUM INTO and verifies it before
| considering it valid.
|
*/
function backupBrivaciaDb(string $file): void {
$db = new PDO('sqlite:' . liveDbFile());
$db->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$tmp = $file . '.tmp';
if (is_file($tmp)) {
unlink($tmp);
}
try {
$db->exec(
'VACUUM INTO ' .
$db->quote($tmp)
);
rename($tmp, $file);
brivaciaLog(
'backup/check.log',
'checking file=' . basename($file)
);
} catch (Throwable $e) {
if (is_file($tmp)) {
unlink($tmp);
}
brivaciaLog(
'backup/failed.log',
'failed file=' . basename($file) . ' error=' . $e->getMessage()
);
throw $e;
}
if (!sqliteIsOk($file, true)) {
brivaciaLog(
'backup/failed.log',
'integrity failed file=' . basename($file)
);
return;
}
brivaciaLog(
'backup/saved.log',
'saved file=' . basename($file)
);
}
/*
|--------------------------------------------------------------------------
| SQLite integrity check
|--------------------------------------------------------------------------
|
| Returns true only if the database exists, is not empty, and passes
| PRAGMA integrity_check.
|
| Set $logFailures to true when the caller needs diagnostics.
|
*/
function sqliteIsOk(string $file, bool $logFailures = false): bool {
if (!is_file($file)) {
if ($logFailures) {
brivaciaLog(
'backup/check.log',
'missing file=' . basename($file)
);
}
return false;
}
if (filesize($file) < 100) {
if ($logFailures) {
brivaciaLog(
'backup/check.log',
'too small file=' . basename($file)
);
}
return false;
}
try {
$db = new PDO('sqlite:' . $file);
$db->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
$result = $db
->query('PRAGMA integrity_check')
->fetchColumn();
if ($result !== 'ok') {
if ($logFailures) {
brivaciaLog(
'backup/check.log',
'integrity failed file=' . basename($file) . ' result=' . (string)$result
);
}
return false;
}
return true;
} catch (Throwable $e) {
if ($logFailures) {
brivaciaLog(
'backup/check.log',
'exception file=' . basename($file) . ' error=' . $e->getMessage()
);
}
return false;
}
}
/*
|--------------------------------------------------------------------------
| Automatic restore
|--------------------------------------------------------------------------
|
| If the live database is broken, moves it to the corrupt directory and
| restores the most recent valid hourly backup.
|
*/
function brivaciaRestoreAlertFile(): string {
return dataDir() . '/restore_alert.json';
}
function brivaciaWriteRestoreAlert(string $backup, string $corrupt): void {
file_put_contents(
brivaciaRestoreAlertFile(),
json_encode([
'time' => date('Y-m-d H:i:s'),
'backup' => basename($backup),
'corrupt' => basename($corrupt),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
LOCK_EX
);
}
function brivaciaRestoreAlert(): array {
$file = brivaciaRestoreAlertFile();
if (!is_file($file)) {
return [];
}
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
function brivaciaClearRestoreAlert(): void {
@unlink(brivaciaRestoreAlertFile());
}
function restoreBrivaciaDbIfBroken(): void {
$live = liveDbFile();
if (!is_file($live)) {
return;
}
if (sqliteIsOk($live)) {
return;
}
brivaciaLog(
'backup/corrupt.log',
'live database failed integrity check'
);
brivaciaLog(
'backup/restore.log',
'restore started'
);
$backups = glob(
backupDir() . '/brivacia-hourly-*.sqlite'
) ?: [];
usort(
$backups,
fn($a, $b) =>
filemtime($b) <=> filemtime($a)
);
foreach ($backups as $backup) {
if (!sqliteIsOk($backup, true)) {
brivaciaLog(
'backup/invalid.log',
'skipped invalid backup ' . basename($backup)
);
continue;
}
if (is_file($live)) {
$corruptFile = corruptDir() .'/brivacia-corrupt-' . date('Ymd-His') . '.sqlite';
rename($live, $corruptFile);
brivaciaLog(
'backup/corrupt.log',
'moved corrupt database to ' . basename($corruptFile)
);
}
copy($backup, $live);
if (!sqliteIsOk($live, true)) {
brivaciaLog(
'backup/failed.log',
'restored database failed integrity check from ' . basename($backup)
);
continue;
}
@unlink(
backupDir() . '/backup.lastcheck'
);
brivaciaLog(
'backup/restore.log',
'restored from ' . basename($backup)
);
brivaciaWriteRestoreAlert($backup, $corruptFile ?? '');
return;
}
brivaciaLog(
'backup/failed.log',
'restore failed: no valid backup available'
);
}