format('Ymd-His'); $directory = $this->directory().'/'.$name; for ($suffix = 2; is_dir($directory) && $suffix < 100; ++$suffix) { $name = sprintf('sicherung-%s-%d', $now->format('Ymd-His'), $suffix); $directory = $this->directory().'/'.$name; } if (is_dir($directory)) { throw new InvalidPage('Es gibt bereits zu viele Sicherungen mit diesem Zeitstempel.'); } if (!@mkdir($directory, 0o775, true)) { throw new InvalidPage(sprintf('Das Verzeichnis %s ließ sich nicht anlegen. Schreibrechte auf var/ prüfen.', $directory)); } $database = $this->copyDatabase($directory); $media = $this->copyMedia($directory.'/medien'); $manifest = [ 'erstellt' => $now->format('c'), 'anlass' => $reason, 'fassung' => $this->version(), // A database that has never been migrated by the tool has no such table; that is not // a reason to refuse a backup of its content. 'migration' => $this->migration(), 'datenbank' => $database, 'medien' => ['dateien' => $media['count'], 'bytes' => $media['bytes']], 'inhalt' => [ 'seiten' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM page WHERE deleted_at IS NULL'), 'fassungen' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'), 'medien' => (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'), ], // Not in here, and it is worth saying so where somebody reads it. 'enthaelt_nicht' => ['.env', '.env.local', 'vendor/', 'Quellcode'], ]; file_put_contents($directory.'/'.self::MANIFEST, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); return ['name' => $name, 'path' => $directory, 'manifest' => $manifest]; } /** * @return list */ public function all(): array { $backups = []; foreach (glob($this->directory().'/sicherung-*', GLOB_ONLYDIR) ?: [] as $path) { $manifest = $this->manifest($path); $backups[] = [ 'name' => basename($path), 'path' => $path, 'bytes' => $this->size($path), 'manifest' => $manifest, // A backup whose database is missing or whose checksum does not match is not a // backup, and it must say so before somebody relies on it. 'intact' => null !== $manifest && $this->intact($path, $manifest), ]; } usort($backups, static fn (array $a, array $b): int => strcmp($b['name'], $a['name'])); return $backups; } /** Removes the oldest beyond the limit. Returns the names it removed. */ public function rotate(int $keep = self::KEEP): array { $removed = []; foreach (array_slice($this->all(), max(1, $keep)) as $backup) { $this->remove($backup['path']); $removed[] = $backup['name']; } return $removed; } /** * Puts a backup back in place. * * @return array{steps: list, ok: bool} */ public function restore(string $name, bool $force = false): array { $steps = []; $step = static function (array &$steps, string $name, bool $ok, string $detail): bool { $steps[] = ['step' => $name, 'ok' => $ok, 'detail' => $detail]; return $ok; }; $path = $this->directory().'/'.basename($name); $manifest = $this->manifest($path); if (!$step($steps, 'Sicherung finden', null !== $manifest, null === $manifest ? sprintf('%s gibt es nicht oder es fehlt das Manifest.', basename($name)) : sprintf('%s vom %s, Fassung %s.', basename($name), $manifest['erstellt'], $manifest['fassung']))) { return ['steps' => $steps, 'ok' => false]; } if (!$step($steps, 'Unversehrt', $this->intact($path, $manifest), $this->intact($path, $manifest) ? 'Die Prüfsumme der Datenbank stimmt.' : 'Die Prüfsumme der Datenbank stimmt nicht. Diese Sicherung wird nicht eingespielt.')) { return ['steps' => $steps, 'ok' => false]; } // A backup from a newer version carries a schema this code does not know. $newer = version_compare((string) $manifest['fassung'], $this->version(), '>'); if (!$step($steps, 'Fassung passt', !$newer || $force, $newer ? sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s. Erst aktualisieren.', $manifest['fassung'], $this->version()) : sprintf('Sicherung %s, installiert %s.', $manifest['fassung'], $this->version()))) { return ['steps' => $steps, 'ok' => false]; } try { $safety = $this->create('vor Wiederherstellung'); $step($steps, 'Aktuellen Stand sichern', true, $safety['name'].' – auch eine Wiederherstellung kann die falsche sein.'); } catch (InvalidPage $error) { $step($steps, 'Aktuellen Stand sichern', false, $error->getMessage()); return ['steps' => $steps, 'ok' => false]; } $file = $this->databaseFile(); if (null === $file) { // MySQL: der Dump wird mit dem Client eingespielt - derselbe Weg, den man von Hand // gehen wuerde, nur ohne Tippfehler in der Verbindung. try { $this->loadMysql($path.'/'.(string) ($manifest['datenbank']['datei'] ?? 'datenbank.sql')); $step($steps, 'Datenbank zurückschreiben', true, 'Der Dump wurde mit dem MySQL-Client eingespielt.'); } catch (InvalidPage $fehler) { $step($steps, 'Datenbank zurückschreiben', false, $fehler->getMessage()); return ['steps' => $steps, 'ok' => false]; } } else { $this->db->close(); $copied = @copy($path.'/'.basename($file), $file); if (!$step($steps, 'Datenbank zurückschreiben', $copied, $copied ? basename($file).' ersetzt.' : 'Die Datei ließ sich nicht ersetzen.')) { return ['steps' => $steps, 'ok' => false]; } } // Media are added, never deleted: files uploaded after the backup would otherwise be lost // by the very operation that is meant to prevent loss. $restored = $this->restoreMedia($path.'/medien'); $step($steps, 'Medien ergänzen', true, sprintf('%d Datei(en) zurückgeschrieben. Neuere Dateien bleiben erhalten.', $restored)); return ['steps' => $steps, 'ok' => true]; } private function migration(): string { try { return (string) $this->db->fetchOne('SELECT MAX(version) FROM migration_versions'); } catch (\Throwable) { return 'unbekannt'; } } public function directory(): string { return $this->root.'/var/backups'; } public function version(): string { $file = $this->root.'/VERSION'; return is_readable($file) ? trim((string) file_get_contents($file)) : '0.0.0'; } /** @return array{datei: string, bytes: int, sha256: string} */ private function copyDatabase(string $directory): array { $file = $this->databaseFile(); if (null === $file) { return $this->dumpMysql($directory); } $target = $directory.'/'.basename($file); // VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann // unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht // wird. Der Pfad kommt von uns, nicht aus einer Eingabe. // // **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung // aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf // einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist // der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent. if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) { $this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target)))); } else { $this->copyUnderLock($file, $target); } if (!is_readable($target)) { throw new InvalidPage('Die Datenbank ließ sich nicht sichern.'); } return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)]; } /** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */ private function sqliteVersion(): string { try { return (string) $this->db->fetchOne('SELECT sqlite_version()'); } catch (\Throwable) { return '0'; } } /** * Eine konsistente Kopie ohne VACUUM INTO. * * `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der * Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie * vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt. * * @throws InvalidPage */ private function copyUnderLock(string $file, string $target): void { try { $this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (\Throwable) { // Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler. } try { $this->db->executeStatement('BEGIN IMMEDIATE'); } catch (\Throwable $fehler) { throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage()); } try { if (!@copy($file, $target)) { throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.'); } } finally { try { $this->db->executeStatement('COMMIT'); } catch (\Throwable) { // Die Sperre faellt spaetestens mit der Verbindung. } } } /** @return array{count: int, bytes: int} */ private function copyMedia(string $target): array { if (!is_dir($this->mediaDirectory)) { return ['count' => 0, 'bytes' => 0]; } @mkdir($target, 0o775, true); $count = 0; $bytes = 0; foreach (glob($this->mediaDirectory.'/*') ?: [] as $file) { if (is_file($file) && @copy($file, $target.'/'.basename($file))) { ++$count; $bytes += (int) filesize($file); } } return ['count' => $count, 'bytes' => $bytes]; } private function restoreMedia(string $source): int { if (!is_dir($source)) { return 0; } @mkdir($this->mediaDirectory, 0o775, true); $count = 0; foreach (glob($source.'/*') ?: [] as $file) { if (is_file($file) && @copy($file, $this->mediaDirectory.'/'.basename($file))) { ++$count; } } return $count; } private function manifest(string $path): ?array { $file = $path.'/'.self::MANIFEST; if (!is_readable($file)) { return null; } try { $manifest = json_decode((string) file_get_contents($file), true, 16, JSON_THROW_ON_ERROR); } catch (\JsonException) { return null; } return is_array($manifest) ? $manifest : null; } private function intact(string $path, array $manifest): bool { $file = $path.'/'.($manifest['datenbank']['datei'] ?? ''); if (!is_readable($file)) { return false; } return hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file)); } private function size(string $path): int { $bytes = 0; foreach (glob($path.'/*') ?: [] as $entry) { $bytes += is_dir($entry) ? $this->size($entry) : (int) filesize($entry); } return $bytes; } private function remove(string $path): void { foreach (glob($path.'/*') ?: [] as $entry) { is_dir($entry) ? $this->remove($entry) : @unlink($entry); } @rmdir($path); } /** * Die Verbindungsdaten einer MySQL/MariaDB-Adresse, oder null bei einer Dateidatenbank. * * @return array{host: string, port: string, user: string, password: string, name: string}|null */ private function mysqlConnection(): ?array { if (1 !== preg_match('#^(mysql|mariadb)://#', $this->databaseUrl)) { return null; } $teile = parse_url($this->databaseUrl); if (!is_array($teile) || '' === trim((string) ($teile['path'] ?? ''), '/')) { return null; } return [ 'host' => (string) ($teile['host'] ?? '127.0.0.1'), 'port' => (string) ($teile['port'] ?? 3306), 'user' => rawurldecode((string) ($teile['user'] ?? '')), 'password' => rawurldecode((string) ($teile['pass'] ?? '')), 'name' => trim((string) $teile['path'], '/'), ]; } /** @return list die Verbindungsargumente fuer mysqldump und mysql */ private function mysqlArguments(array $verbindung): array { $args = ['--host='.$verbindung['host'], '--port='.$verbindung['port'], '--user='.$verbindung['user']]; if ('' !== $verbindung['password']) { // Als Argument, nicht als Umgebungsvariable: der Befehlslaeufer startet mit einer // festen Umgebung. Das Argument ist fuer andere Nutzer des Servers kurz sichtbar - // wie bei jedem mysqldump-Aufruf von Hand. $args[] = '--password='.$verbindung['password']; } return $args; } /** @return array{datei: string, bytes: int, sha256: string} */ private function dumpMysql(string $directory): array { $verbindung = $this->mysqlConnection(); if (null === $verbindung) { throw new InvalidPage('Die Datenbankadresse ist weder eine SQLite-Datei noch eine MySQL-Verbindung.'); } $dump = $this->runner->resolve('mysqldump', $this->root); if (null === $dump) { throw new InvalidPage('Diese Installation nutzt MySQL, und mysqldump ist nicht aufrufbar. Ohne den Client gibt es keine Sicherung - beim Hoster nach dem Pfad fragen.'); } $target = $directory.'/datenbank.sql'; // --single-transaction: ein konsistenter Stand ohne Sperre der Tabellen; --result-file: // direkt in die Datei, nicht durch den Speicher dieses Prozesses. $ergebnis = $this->runner->run([...$dump, ...$this->mysqlArguments($verbindung), '--single-transaction', '--quick', '--skip-lock-tables', '--routines', '--result-file='.$target, $verbindung['name']], $this->root, 900); if (!$ergebnis['ok'] || !is_readable($target) || 0 === (int) filesize($target)) { @unlink($target); throw new InvalidPage('mysqldump ist gescheitert: '.trim($ergebnis['output'])); } return ['datei' => 'datenbank.sql', 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)]; } /** Spielt einen Dump mit dem MySQL-Client ein. @throws InvalidPage */ private function loadMysql(string $dump): void { $verbindung = $this->mysqlConnection(); if (null === $verbindung) { throw new InvalidPage('Die Datenbankadresse ist keine MySQL-Verbindung.'); } if (!is_readable($dump)) { throw new InvalidPage('Der Dump fehlt in der Sicherung: '.basename($dump)); } $client = $this->runner->resolve('mysql', $this->root); if (null === $client) { throw new InvalidPage('Der MySQL-Client (mysql) ist nicht aufrufbar; der Dump liegt unter '.$dump.' und lässt sich von Hand einspielen.'); } $this->db->close(); $ergebnis = $this->runner->run([...$client, ...$this->mysqlArguments($verbindung), '--database='.$verbindung['name'], '--execute=source '.str_replace('\\', '/', $dump)], $this->root, 900); if (!$ergebnis['ok']) { throw new InvalidPage('Der MySQL-Client hat den Dump nicht eingespielt: '.trim($ergebnis['output'])); } } private function databaseFile(): ?string { if (!str_starts_with($this->databaseUrl, 'sqlite:')) { return null; } $path = preg_replace('#^sqlite:(///)?#', '', $this->databaseUrl); return '' === $path ? null : str_replace('\\', '/', (string) $path); } }