Forms gained steps, conditions, image choices, number fields with units and a summary step; the DEEG price finder uses all of it. Plugins are manifest plus Twig: blocks, settings, containers, routes, presets and theme packs, switched per installation. The review backlog is done: archive pages render through the normal page pipeline, forms and post lists work inside areas and sections, repeated sections get their own ids, breakpoints and colours come from the palette, backup and update work on MySQL and lock each other out. The task worker no longer shortens a process that has no time limit: set_time_limit() sets the limit instead of extending it, which turned an unlimited CLI run into eighty seconds and killed the full test suite far away from the scheduler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
464 lines
19 KiB
PHP
464 lines
19 KiB
PHP
<?php
|
||
|
||
namespace App\Ops;
|
||
|
||
use App\Domain\InvalidPage;
|
||
use Doctrine\DBAL\Connection;
|
||
|
||
/**
|
||
* Sicherung und Wiederherstellung.
|
||
*
|
||
* Three things decide whether this is worth anything:
|
||
*
|
||
* 1. **The database copy has to be consistent.** A plain `copy()` of a SQLite file while something
|
||
* writes produces a file that looks fine and opens with an error later. `VACUUM INTO` asks
|
||
* SQLite for a complete copy instead.
|
||
* 2. **The media folder belongs in it.** Documents reference image IDs; without the files the
|
||
* backup restores a site full of broken pictures.
|
||
* 3. **`.env` does not.** It holds APP_SECRET, and a backup is something that gets downloaded and
|
||
* passed around. It is also not needed to restore content.
|
||
*
|
||
* Restoring is the part nobody practises, so it is the part with the most checks: a backup from a
|
||
* newer version is refused, a safety copy of the current state is taken first, and the media are
|
||
* added rather than replaced - deleting files that were uploaded after the backup would turn a
|
||
* restore into a second loss.
|
||
*/
|
||
final class Backup
|
||
{
|
||
public const MANIFEST = 'sicherung.json';
|
||
/** Keep this many, newest first. Without rotation the disk fills and the next backup fails. */
|
||
public const KEEP = 10;
|
||
|
||
public function __construct(
|
||
private string $root,
|
||
private string $databaseUrl,
|
||
private string $mediaDirectory,
|
||
private Connection $db,
|
||
private CommandRunner $runner,
|
||
/**
|
||
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
|
||
*
|
||
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
|
||
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
|
||
* ungeprueftes Versprechen.
|
||
*/
|
||
private string $vacuumSince = '3.27.0',
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* @return array{name: string, path: string, manifest: array}
|
||
*
|
||
* @throws InvalidPage
|
||
*/
|
||
public function create(string $reason = 'manuell', \DateTimeImmutable $now = new \DateTimeImmutable('now', new \DateTimeZone('UTC'))): array
|
||
{
|
||
// Two backups within the same second must not collide - and the second one must not
|
||
// fail either: a restore takes a safety copy, and that can land in the same second as the
|
||
// backup it is about to restore.
|
||
$name = 'sicherung-'.$now->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<array{name: string, path: string, bytes: int, manifest: ?array, intact: bool}>
|
||
*/
|
||
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<array{step: string, ok: bool, detail: string}>, 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<string> 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);
|
||
}
|
||
}
|