467 lines
20 KiB
PHP
467 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class BackupService
|
|
{
|
|
private const FORMAT_VERSION = 1;
|
|
|
|
public static function create(string $reason = 'manual'): array
|
|
{
|
|
if (!class_exists(ZipArchive::class)) {
|
|
throw new RuntimeException(t('service.backup.zip_missing'));
|
|
}
|
|
$root = dirname(__DIR__, 2);
|
|
$backupDir = $root . '/storage/backups';
|
|
if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
|
|
throw new RuntimeException(t('service.backup.directory_failed'));
|
|
}
|
|
$stamp = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('Ymd-His');
|
|
$suffix = $reason === 'pre-restore' ? '-avant-restauration' : '';
|
|
$target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip';
|
|
// Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas
|
|
// y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au
|
|
// répertoire temporaire système ; seule l'archive finale va dans backups/.
|
|
$snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-');
|
|
if ($snapshot === false) {
|
|
throw new RuntimeException(t('service.backup.prepare_failed'));
|
|
}
|
|
@unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas.
|
|
|
|
$pdo = DB::pdo();
|
|
$pdo->exec('PRAGMA wal_checkpoint(FULL)');
|
|
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
|
|
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
|
|
@unlink($snapshot);
|
|
throw new RuntimeException(t('service.backup.create_failed'));
|
|
}
|
|
$files = [];
|
|
try {
|
|
self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files);
|
|
if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') {
|
|
self::addTree($zip, $root . '/public/media', 'public/media', $files);
|
|
self::addTree($zip, $root . '/data/association', 'data/association', $files);
|
|
self::addTree($zip, $root . '/data/grants', 'data/grants', $files);
|
|
self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files);
|
|
self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files);
|
|
}
|
|
$manifest = [
|
|
'application' => 'Globinours',
|
|
'format_version' => self::FORMAT_VERSION,
|
|
'created_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format(
|
|
DateTimeInterface::ATOM,
|
|
),
|
|
'reason' => $reason,
|
|
'php_version' => PHP_VERSION,
|
|
'files' => $files,
|
|
];
|
|
$zip->addFromString(
|
|
'manifest.json',
|
|
json_encode(
|
|
$manifest,
|
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
|
|
),
|
|
);
|
|
} finally {
|
|
$zip->close();
|
|
@unlink($snapshot);
|
|
}
|
|
@chmod($target, 0600);
|
|
self::prune($backupDir);
|
|
return self::describe($target);
|
|
}
|
|
|
|
public static function list(): array
|
|
{
|
|
$dir = dirname(__DIR__, 2) . '/storage/backups';
|
|
$files = glob($dir . '/globinours-*.zip') ?: [];
|
|
usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
|
|
return array_map([self::class, 'describe'], $files);
|
|
}
|
|
|
|
public static function path(string $name): ?string
|
|
{
|
|
if (
|
|
$name !== basename($name) ||
|
|
!preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name)
|
|
) {
|
|
return null;
|
|
}
|
|
$path = dirname(__DIR__, 2) . '/storage/backups/' . $name;
|
|
return is_file($path) ? $path : null;
|
|
}
|
|
|
|
public static function delete(string $name): void
|
|
{
|
|
$path = self::path($name);
|
|
if (!$path) {
|
|
throw new RuntimeException(t('service.backup.not_found'));
|
|
}
|
|
if (!unlink($path)) {
|
|
throw new RuntimeException(t('service.backup.delete_failed'));
|
|
}
|
|
}
|
|
|
|
public static function verify(string $name): array
|
|
{
|
|
$path = self::path($name);
|
|
if (!$path) {
|
|
throw new RuntimeException(t('service.backup.not_found'));
|
|
}
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($path) !== true) {
|
|
throw new RuntimeException(t('service.backup.unreadable'));
|
|
}
|
|
try {
|
|
$raw = $zip->getFromName('manifest.json');
|
|
if ($raw === false) {
|
|
throw new RuntimeException(t('service.backup.manifest_missing'));
|
|
}
|
|
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
if (
|
|
($manifest['application'] ?? '') !== 'Globinours' ||
|
|
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
|
|
) {
|
|
throw new RuntimeException(t('service.backup.incompatible'));
|
|
}
|
|
foreach ($manifest['files'] ?? [] as $file => $meta) {
|
|
$stream = $zip->getStream((string) $file);
|
|
if (!$stream) {
|
|
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
|
|
}
|
|
$context = hash_init('sha256');
|
|
hash_update_stream($context, $stream);
|
|
fclose($stream);
|
|
if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) {
|
|
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
|
|
}
|
|
}
|
|
$tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-');
|
|
if ($tmp === false) {
|
|
throw new RuntimeException(t('service.backup.prepare_failed'));
|
|
}
|
|
$dbBytes = $zip->getFromName('database/refuge.sqlite');
|
|
if ($dbBytes === false) {
|
|
@unlink($tmp);
|
|
throw new RuntimeException(t('service.backup.database_missing'));
|
|
}
|
|
file_put_contents($tmp, $dbBytes);
|
|
self::validateDatabase($tmp);
|
|
@unlink($tmp);
|
|
return [
|
|
'valid' => true,
|
|
'name' => $name,
|
|
'created_at' => (string) ($manifest['created_at'] ?? ''),
|
|
'files' => count($manifest['files'] ?? []),
|
|
];
|
|
} finally {
|
|
$zip->close();
|
|
}
|
|
}
|
|
|
|
public static function runScheduled(bool $force = false): array
|
|
{
|
|
if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) {
|
|
return ['created' => false, 'reason' => 'disabled'];
|
|
}
|
|
$frequency = AppSettings::get('backup_schedule_frequency');
|
|
$last = AppSettings::get('backup_last_run_at');
|
|
$minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600;
|
|
if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) {
|
|
return ['created' => false, 'reason' => 'not_due'];
|
|
}
|
|
$backup = self::create('scheduled');
|
|
$verification = self::verify($backup['name']);
|
|
AppSettings::save(
|
|
['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')],
|
|
null,
|
|
);
|
|
return ['created' => true, 'backup' => $backup, 'verification' => $verification];
|
|
}
|
|
|
|
public static function restore(string $uploadedPath): array
|
|
{
|
|
if (!is_file($uploadedPath)) {
|
|
throw new RuntimeException(t('service.backup.restore_missing'));
|
|
}
|
|
$root = dirname(__DIR__, 2);
|
|
$stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6));
|
|
if (!mkdir($stage, 0700, true)) {
|
|
throw new RuntimeException(t('service.backup.prepare_failed'));
|
|
}
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($uploadedPath) !== true) {
|
|
self::removeTree($stage);
|
|
throw new RuntimeException(t('service.backup.unreadable'));
|
|
}
|
|
try {
|
|
if ($zip->numFiles > 10000) {
|
|
throw new RuntimeException(t('service.backup.too_many_files'));
|
|
}
|
|
$total = 0;
|
|
$seen = [];
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$stat = $zip->statIndex($i);
|
|
$name = (string) ($stat['name'] ?? '');
|
|
if (isset($seen[$name])) {
|
|
throw new RuntimeException(t('service.backup.unsafe_path'));
|
|
}
|
|
$seen[$name] = true;
|
|
$total += (int) ($stat['size'] ?? 0);
|
|
if ($total > 2_000_000_000) {
|
|
throw new RuntimeException(t('service.backup.too_large'));
|
|
}
|
|
if (
|
|
$name === '' ||
|
|
str_contains($name, "\0") ||
|
|
str_starts_with($name, '/') ||
|
|
preg_match('#(^|/)\.\.(/|$)#', $name)
|
|
) {
|
|
throw new RuntimeException(t('service.backup.unsafe_path'));
|
|
}
|
|
}
|
|
$manifestRaw = $zip->getFromName('manifest.json');
|
|
if ($manifestRaw === false) {
|
|
throw new RuntimeException(t('service.backup.manifest_missing'));
|
|
}
|
|
$manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR);
|
|
if (
|
|
($manifest['application'] ?? '') !== 'Globinours' ||
|
|
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
|
|
) {
|
|
throw new RuntimeException(t('service.backup.incompatible'));
|
|
}
|
|
$manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []);
|
|
$allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true);
|
|
for ($i = 0; $i < $zip->numFiles; $i++) {
|
|
$stat = $zip->statIndex($i);
|
|
$name = (string) ($stat['name'] ?? '');
|
|
if (str_ends_with($name, '/')) {
|
|
continue;
|
|
}
|
|
if (!isset($allowed[$name])) {
|
|
throw new RuntimeException(t('service.backup.manifest_path'));
|
|
}
|
|
if (method_exists($zip, 'getExternalAttributesIndex')) {
|
|
$opsys = 0;
|
|
$attr = 0;
|
|
if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) {
|
|
throw new RuntimeException(t('service.backup.unsafe_path'));
|
|
}
|
|
}
|
|
}
|
|
$written = 0;
|
|
foreach ($manifestFiles as $name) {
|
|
$source = $zip->getStream($name);
|
|
if (!$source) {
|
|
throw new RuntimeException(t('service.backup.extract_failed'));
|
|
}
|
|
$target = $stage . '/' . $name;
|
|
$dir = dirname($target);
|
|
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
fclose($source);
|
|
throw new RuntimeException(t('service.backup.extract_failed'));
|
|
}
|
|
$dest = fopen($target, 'xb');
|
|
if (!$dest) {
|
|
fclose($source);
|
|
throw new RuntimeException(t('service.backup.extract_failed'));
|
|
}
|
|
while (!feof($source)) {
|
|
$chunk = fread($source, 1048576);
|
|
if ($chunk === false) {
|
|
fclose($source);
|
|
fclose($dest);
|
|
throw new RuntimeException(t('service.backup.extract_failed'));
|
|
}
|
|
$written += strlen($chunk);
|
|
if ($written > 2_000_000_000) {
|
|
fclose($source);
|
|
fclose($dest);
|
|
throw new RuntimeException(t('service.backup.too_large'));
|
|
}
|
|
fwrite($dest, $chunk);
|
|
}
|
|
fclose($source);
|
|
fclose($dest);
|
|
@chmod($target, 0600);
|
|
}
|
|
} finally {
|
|
$zip->close();
|
|
}
|
|
|
|
try {
|
|
foreach ($manifest['files'] ?? [] as $name => $metadata) {
|
|
if (
|
|
!is_string($name) ||
|
|
!preg_match(
|
|
'#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#',
|
|
$name,
|
|
)
|
|
) {
|
|
throw new RuntimeException(t('service.backup.manifest_path'));
|
|
}
|
|
$restored = $stage . '/' . $name;
|
|
$hash = (string) ($metadata['sha256'] ?? '');
|
|
if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) {
|
|
throw new RuntimeException(t('service.backup.file_altered', ['name' => $name]));
|
|
}
|
|
}
|
|
$database = $stage . '/database/refuge.sqlite';
|
|
if (!is_file($database)) {
|
|
throw new RuntimeException(t('service.backup.database_missing'));
|
|
}
|
|
$expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? '';
|
|
if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) {
|
|
throw new RuntimeException(t('service.backup.database_hash'));
|
|
}
|
|
self::validateDatabase($database);
|
|
$emergency = self::create('pre-restore');
|
|
self::replaceInstallation($stage, $root);
|
|
return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')];
|
|
} finally {
|
|
self::removeTree($stage);
|
|
}
|
|
}
|
|
|
|
private static function validateDatabase(string $path): void
|
|
{
|
|
$db = new SQLite3($path, SQLITE3_OPEN_READONLY);
|
|
try {
|
|
if ($db->querySingle('PRAGMA integrity_check') !== 'ok') {
|
|
throw new RuntimeException(t('service.backup.database_corrupt'));
|
|
}
|
|
foreach (['animals', 'users', 'animal_history'] as $table) {
|
|
$safe = SQLite3::escapeString($table);
|
|
if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) {
|
|
throw new RuntimeException(t('service.backup.table_missing', ['table' => $table]));
|
|
}
|
|
}
|
|
} finally {
|
|
$db->close();
|
|
}
|
|
}
|
|
|
|
private static function replaceInstallation(string $stage, string $root): void
|
|
{
|
|
$rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5));
|
|
mkdir($rollback, 0700, true);
|
|
$targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media'];
|
|
$moved = [];
|
|
try {
|
|
foreach ($targets as $relative) {
|
|
$live = $root . '/' . $relative;
|
|
$saved = $rollback . '/' . str_replace('/', '__', $relative);
|
|
$incoming = $stage . '/' . $relative;
|
|
if (is_dir($live) && !rename($live, $saved)) {
|
|
throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative]));
|
|
}
|
|
if (is_dir($saved)) {
|
|
$moved[$relative] = $saved;
|
|
}
|
|
if (is_dir($incoming) && !rename($incoming, $live)) {
|
|
throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative]));
|
|
}
|
|
if (!is_dir($live)) {
|
|
mkdir($live, 0775, true);
|
|
}
|
|
}
|
|
$pdo = DB::pdo();
|
|
$pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
unset($pdo);
|
|
DB::close();
|
|
foreach (['-wal', '-shm'] as $suffix) {
|
|
@unlink($root . '/data/refuge.sqlite' . $suffix);
|
|
}
|
|
if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) {
|
|
throw new RuntimeException(t('service.backup.secure_database_failed'));
|
|
}
|
|
if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) {
|
|
throw new RuntimeException(t('service.backup.restore_database_failed'));
|
|
}
|
|
@chmod($root . '/data/refuge.sqlite', 0600);
|
|
self::removeTree($rollback);
|
|
} catch (Throwable $e) {
|
|
DB::close();
|
|
if (is_file($rollback . '/refuge.sqlite')) {
|
|
@unlink($root . '/data/refuge.sqlite');
|
|
@rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite');
|
|
}
|
|
foreach ($targets as $relative) {
|
|
$live = $root . '/' . $relative;
|
|
$saved = $rollback . '/' . str_replace('/', '__', $relative);
|
|
if (is_dir($saved)) {
|
|
self::removeTree($live);
|
|
@rename($saved, $live);
|
|
}
|
|
}
|
|
self::removeTree($rollback);
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile() && !$file->isLink()) {
|
|
self::addFile(
|
|
$zip,
|
|
$file->getPathname(),
|
|
$prefix . '/' . substr($file->getPathname(), strlen($dir) + 1),
|
|
$files,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void
|
|
{
|
|
if (!is_file($source)) {
|
|
return;
|
|
}
|
|
$name = str_replace('\\', '/', $name);
|
|
if (!$zip->addFile($source, $name)) {
|
|
throw new RuntimeException(t('service.backup.add_failed', ['name' => $name]));
|
|
}
|
|
$files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)];
|
|
}
|
|
|
|
private static function describe(string $path): array
|
|
{
|
|
$modified = new DateTimeImmutable('@' . (filemtime($path) ?: time()))->setTimezone(
|
|
new DateTimeZone('Europe/Paris'),
|
|
);
|
|
return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')];
|
|
}
|
|
|
|
private static function prune(string $dir): void
|
|
{
|
|
$files = glob($dir . '/globinours-*.zip') ?: [];
|
|
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
|
$keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10;
|
|
foreach (array_slice($files, $keep) as $file) {
|
|
@unlink($file);
|
|
}
|
|
}
|
|
|
|
private static function removeTree(string $dir): void
|
|
{
|
|
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);
|
|
}
|
|
}
|