- Own CSS is parsed on write only. A page whose stored CSS no longer passes a stricter rule answers 200 and opens in the editor; that one block loses its CSS. A document read several times in one request is validated once. - Settings and theme refuse a write from an outdated form. - A backup fails when a media file does not arrive completely and leaves no half directory behind; rotation counts intact backups only. - The archive update takes its backup behind the maintenance page. - A package import puts all media in place before removing the old ones, reports honestly when that fails and keeps a maintenance state that was already on. - Restoring replaces the SQLite file in one move and points at pending migrations. - Six irreversible actions ask first, in a dialog of the admin's own. - Ctrl+S in the editor takes the field being typed in along; Delete no longer removes a block while a dialog is open. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
526 lines
23 KiB
PHP
526 lines
23 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));
|
||
}
|
||
|
||
// Scheitert etwas - typisch: der Speicher ist voll -, bleibt kein halber Ordner liegen. Der
|
||
// zählte sonst bei der Rotation als eine der neuesten Sicherungen und verdrängte eine
|
||
// vollständige.
|
||
try {
|
||
return $this->write($name, $directory, $reason, $now);
|
||
} catch (\Throwable $fehler) {
|
||
$this->remove($directory);
|
||
|
||
throw $fehler;
|
||
}
|
||
}
|
||
|
||
/** @return array{name: string, path: string, manifest: array} */
|
||
private function write(string $name, string $directory, string $reason, \DateTimeImmutable $now): array
|
||
{
|
||
$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'],
|
||
];
|
||
$text = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||
if (strlen($text) !== @file_put_contents($directory.'/'.self::MANIFEST, $text)) {
|
||
throw new InvalidPage('Das Verzeichnis der Sicherung ließ sich nicht vollständig schreiben. Freien Speicherplatz prüfen.');
|
||
}
|
||
|
||
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.
|
||
*
|
||
* Gezählt werden nur unversehrte Sicherungen: eine beschädigte darf keine vollständige
|
||
* verdrängen. Beschädigte verschwinden selbst - aber erst nach einer Stunde, denn ein Ordner
|
||
* ohne Verzeichnis kann auch eine Sicherung sein, die eine andere Anfrage gerade schreibt.
|
||
*/
|
||
public function rotate(int $keep = self::KEEP): array
|
||
{
|
||
$removed = [];
|
||
$intact = 0;
|
||
foreach ($this->all() as $backup) {
|
||
$drop = $backup['intact'] ? ++$intact > max(1, $keep) : (int) @filemtime($backup['path']) < time() - 3600;
|
||
if ($drop) {
|
||
$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();
|
||
// Erst vollständig daneben ablegen, dann in einem Zug an die Stelle setzen: eine halb
|
||
// geschriebene Datenbank sieht so nie jemand. Journal und WAL der alten Datei müssen
|
||
// weg - SQLite spielte sie sonst beim nächsten Öffnen in die zurückgeholte Datei ein.
|
||
$quelle = $path.'/'.basename($file);
|
||
$neben = $file.'.wiederherstellung';
|
||
$copied = @copy($quelle, $neben) && filesize($quelle) === filesize($neben);
|
||
if ($copied) {
|
||
foreach (['-journal', '-wal', '-shm'] as $rest) {
|
||
@unlink($file.$rest);
|
||
}
|
||
// Unter Windows scheitert das Umbenennen, solange ein anderer Prozess die Datei
|
||
// offen hat; dann bleibt der bisherige Weg.
|
||
$copied = @rename($neben, $file) || @copy($neben, $file);
|
||
}
|
||
@unlink($neben);
|
||
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));
|
||
// Eine Sicherung aus einer älteren Fassung bringt deren Datenbankschema mit. Der Code hier
|
||
// ist neuer und erwartet Spalten, die es dann noch nicht gibt - das muss dastehen, sonst
|
||
// sucht man die 500er an der falschen Stelle.
|
||
if (version_compare((string) $manifest['fassung'], $this->version(), '<')) {
|
||
$step($steps, 'Migrationen', true, sprintf('Die Sicherung stammt aus Fassung %s, installiert ist %s: Jetzt in der Verwaltung unter System „Migrationen ausführen“ – oder: php bin/console doctrine:migrations:migrate.', $manifest['fassung'], $this->version()));
|
||
}
|
||
|
||
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)) {
|
||
continue;
|
||
}
|
||
$size = (int) filesize($file);
|
||
// Eine Kopie, die fehlt oder kürzer ist, ist keine: lieber gar keine Sicherung als
|
||
// eine, die vollständig aussieht und beim Zurückspielen Bilder vermissen lässt.
|
||
if (!@copy($file, $target.'/'.basename($file)) || $size !== (int) @filesize($target.'/'.basename($file))) {
|
||
throw new InvalidPage(sprintf('Die Mediendatei %s ließ sich nicht sichern. Freien Speicherplatz und Schreibrechte auf var/backups prüfen.', basename($file)));
|
||
}
|
||
++$count;
|
||
$bytes += $size;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
if (!hash_equals((string) ($manifest['datenbank']['sha256'] ?? ''), (string) hash_file('sha256', $file))) {
|
||
return false;
|
||
}
|
||
// Auch die Medien gehören dazu: fehlt eine der gezählten Dateien, ist die Sicherung nicht
|
||
// mehr die, die das Verzeichnis beschreibt.
|
||
$erwartet = $manifest['medien']['dateien'] ?? null;
|
||
|
||
return !is_int($erwartet) || $erwartet <= count(array_filter(glob($path.'/medien/*') ?: [], 'is_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);
|
||
}
|
||
}
|