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