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'
    );
}