Fix n.n.: Verwaltung – Kachel Backup mit Backup und Restore der gesamten Datenbank

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-01 16:08:53 +02:00
parent 0a8b1f506c
commit 9dbd507433
6 changed files with 347 additions and 0 deletions

View file

@ -1,5 +1,11 @@
# Änderungen
## Verwaltung: Backup und Restore (01.10.2026)
- Neue Kachel „Backup“: „Backup“ lädt den gesamten Datenbestand als `finanzmanager-sicherung-….json.gz` herunter, „Restore“ ersetzt den Bestand durch den Inhalt einer solchen Datei (mit Rückfrage).
- `Services/Datenbanksicherung`: alle Tabellen mit Spalten und Zeilen; Restore in einer Transaktion (DELETE/INSERT), bei Fehler bleibt der alte Bestand. Abgelehnt werden fremde Dateien und Sicherungen, deren Spalten nicht zum Schema passen.
- Test: `tests/55_DatenbanksicherungTest.php`.
## Echtzeitüberweisung: offener Status ist kein Fehler (01.10.2026)
- Quittiert die Bank die TAN mit 3045 („noch keine Rückmeldung zum Status“), meldete php-fints „Bank did not confirm SEPATransfer execution“, obwohl der Auftrag bei der Bank liegt. Jetzt gilt der Auftrag als gesendet (gebucht), die Meldung weist darauf hin, den Kontoumsatz zu prüfen und nicht erneut zu senden.

View file

@ -347,6 +347,8 @@ $router->post('/verwaltung/import/{datei}/loeschen', fn(array $p) => (new Import
// Der Filter kommt als POST: Sind viele Kategorien angehakt, wird eine
// GET-Adresse länger, als der Webserver annimmt. Die GET-Route bleibt für den
// ersten Aufruf und für kurze Adressen, die man sich merken mag.
$router->get('/verwaltung/sicherung', fn() => (new VerwaltungController())->sicherungHerunterladen());
$router->post('/verwaltung/sicherung', fn() => (new VerwaltungController())->sicherungEinspielen());
$router->get('/verwaltung/export', fn() => (new ExportController())->index());
$router->post('/verwaltung/export', fn() => (new ExportController())->index());
$router->get('/verwaltung/export/datei', fn() => (new ExportController())->datei());

View file

@ -18,6 +18,7 @@ use Finanzmanager\Models\Vertrag;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\BargeldTool;
use Finanzmanager\Services\Crypto;
use Finanzmanager\Services\Datenbanksicherung;
use Finanzmanager\View;
/**
@ -296,4 +297,48 @@ class VerwaltungController extends BaseController
'buchungen' => Buchung::mitBargeldFehler($fehlt ? 'fehlt' : 'fehler'),
]);
}
/** Die gesamte Datenbank als Download. */
public function sicherungHerunterladen(): void
{
$this->verlangeAnmeldung();
try {
$inhalt = Datenbanksicherung::erzeugen();
} catch (\Throwable $e) {
View::redirect('/verwaltung', 'Backup fehlgeschlagen: ' . $e->getMessage(), 'fehler');
return;
}
header('Content-Type: application/gzip');
header('Content-Disposition: attachment; filename="' . Datenbanksicherung::dateiname() . '"');
header('Content-Length: ' . strlen($inhalt));
header('Cache-Control: no-store');
echo $inhalt;
}
/** Ersetzt den gesamten Datenbestand durch den Inhalt einer hochgeladenen Sicherung. */
public function sicherungEinspielen(): void
{
$this->verlangeAnmeldung();
$datei = $_FILES['sicherung'] ?? null;
if (!is_array($datei) || ($datei['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK
|| !is_uploaded_file((string)$datei['tmp_name'])) {
View::redirect('/verwaltung', 'Es wurde keine Sicherung hochgeladen.', 'fehler');
return;
}
try {
$anzahl = Datenbanksicherung::wiederherstellen((string)file_get_contents((string)$datei['tmp_name']));
} catch (\Throwable $e) {
View::redirect('/verwaltung', 'Restore fehlgeschlagen, der Bestand blieb unverändert: ' . $e->getMessage(), 'fehler');
return;
}
View::redirect(
'/verwaltung',
'Restore abgeschlossen: ' . array_sum($anzahl) . ' Zeilen in ' . count($anzahl) . ' Tabellen eingespielt.'
);
}
}

View file

@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace Finanzmanager\Services;
use Finanzmanager\Database;
use RuntimeException;
/**
* Sichert den gesamten Inhalt der Datenbank in eine Datei und liest ihn
* wieder ein.
*
* Die Sicherung ist ein gzip-komprimiertes JSON: je Tabelle die Spalten und
* alle Zeilen. Das Schema selbst gehört den Migrationen und steht nicht
* darin – eine Sicherung passt deshalb nur zu einem Stand mit denselben
* Spalten.
*
* Das Wiederherstellen läuft in einer Transaktion (nur DELETE und INSERT,
* kein TRUNCATE – das würde die Transaktion beenden): Scheitert es
* irgendwo, bleibt der alte Bestand unberührt.
*/
class Datenbanksicherung
{
private const KENNUNG = 'finanzmanager-sicherung';
private const VERSION = 1;
/** Zeilen je INSERT beim Einspielen. */
private const BLOCK = 200;
/** @return list<string> */
public static function tabellen(): array
{
$namen = [];
foreach (Database::fetchAll("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'") as $zeile) {
$namen[] = (string)reset($zeile);
}
sort($namen);
return $namen;
}
/** Die komplette Sicherung als gzip-Bytes. */
public static function erzeugen(): string
{
$tabellen = [];
foreach (self::tabellen() as $name) {
$spalten = self::spalten($name);
$zeilen = [];
foreach (Database::fetchAll('SELECT * FROM ' . self::bezeichner($name)) as $zeile) {
$werte = [];
foreach ($spalten as $spalte) {
$werte[] = self::kodiere($zeile[$spalte]);
}
$zeilen[] = $werte;
}
$tabellen[$name] = ['spalten' => $spalten, 'zeilen' => $zeilen];
}
$json = json_encode([
'kennung' => self::KENNUNG,
'version' => self::VERSION,
'erstellt' => date('c'),
'tabellen' => $tabellen,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
$gzip = gzencode($json, 6);
if ($gzip === false) {
throw new RuntimeException('Die Sicherung ließ sich nicht komprimieren.');
}
return $gzip;
}
/** Dateiname für den Download. */
public static function dateiname(): string
{
return 'finanzmanager-sicherung-' . date('Y-m-d_His') . '.json.gz';
}
/**
* Spielt eine Sicherung ein und ersetzt dabei den gesamten Bestand.
*
* @return array<string,int> Zeilen je Tabelle
*/
public static function wiederherstellen(string $inhalt): array
{
$daten = self::lies($inhalt);
$vorhanden = self::tabellen();
foreach ($daten['tabellen'] as $name => $tabelle) {
if (!in_array($name, $vorhanden, true)) {
throw new RuntimeException('Die Tabelle „' . $name . '“ gibt es in dieser Installation nicht.');
}
if (self::spalten($name) !== $tabelle['spalten']) {
throw new RuntimeException(
'Die Spalten der Tabelle „' . $name . '“ passen nicht zum Stand dieser Installation.'
);
}
}
$pdo = Database::get();
return Database::transaktion(static function () use ($daten, $vorhanden, $pdo): array {
$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
try {
$anzahl = [];
foreach ($vorhanden as $name) {
Database::execute('DELETE FROM ' . self::bezeichner($name));
}
foreach ($daten['tabellen'] as $name => $tabelle) {
$anzahl[$name] = self::einspielen($name, $tabelle['spalten'], $tabelle['zeilen']);
}
} finally {
$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
}
return $anzahl;
});
}
/**
* @param list<string> $spalten
* @param list<list<mixed>> $zeilen
*/
private static function einspielen(string $name, array $spalten, array $zeilen): int
{
if ($zeilen === []) {
return 0;
}
$kopf = 'INSERT INTO ' . self::bezeichner($name) . ' ('
. implode(', ', array_map([self::class, 'bezeichner'], $spalten)) . ') VALUES ';
$zeile = '(' . implode(', ', array_fill(0, count($spalten), '?')) . ')';
foreach (array_chunk($zeilen, self::BLOCK) as $block) {
$werte = [];
foreach ($block as $datensatz) {
if (!is_array($datensatz) || count($datensatz) !== count($spalten)) {
throw new RuntimeException('Beschädigte Zeile in der Tabelle „' . $name . '“.');
}
foreach ($datensatz as $wert) {
$werte[] = self::dekodiere($wert);
}
}
Database::execute($kopf . implode(', ', array_fill(0, count($block), $zeile)), $werte);
}
return count($zeilen);
}
/** @return array{tabellen: array<string,array{spalten: list<string>, zeilen: list<mixed>}>} */
private static function lies(string $inhalt): array
{
$json = @gzdecode($inhalt);
if ($json === false) {
throw new RuntimeException('Die Datei ist keine Sicherung (gzip erwartet).');
}
try {
$daten = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
throw new RuntimeException('Die Datei ist keine Sicherung (kein gültiges JSON).');
}
if (!is_array($daten) || ($daten['kennung'] ?? null) !== self::KENNUNG) {
throw new RuntimeException('Die Datei ist keine Sicherung des Finanzmanagers.');
}
if (($daten['version'] ?? null) !== self::VERSION) {
throw new RuntimeException('Die Version der Sicherung wird nicht unterstützt.');
}
if (!is_array($daten['tabellen'] ?? null)) {
throw new RuntimeException('Die Sicherung enthält keine Tabellen.');
}
foreach ($daten['tabellen'] as $tabelle) {
if (!is_array($tabelle) || !is_array($tabelle['spalten'] ?? null) || !is_array($tabelle['zeilen'] ?? null)) {
throw new RuntimeException('Die Sicherung ist beschädigt.');
}
}
return $daten;
}
/** @return list<string> */
private static function spalten(string $tabelle): array
{
return array_map(
static fn(array $z): string => (string)$z['Field'],
Database::fetchAll('SHOW COLUMNS FROM ' . self::bezeichner($tabelle))
);
}
/** Bezeichner sind nie Eingabe, sondern kommen aus dem Schema oder werden hier geprüft. */
private static function bezeichner(string $name): string
{
if (preg_match('/^[A-Za-z0-9_]+$/', $name) !== 1) {
throw new RuntimeException('Ungültiger Bezeichner: ' . $name);
}
return '`' . $name . '`';
}
/** Binärwerte, die kein UTF-8 sind, wandern als Base64 in die Datei. */
private static function kodiere(mixed $wert): mixed
{
if (is_string($wert) && !mb_check_encoding($wert, 'UTF-8')) {
return ['b64' => base64_encode($wert)];
}
return $wert;
}
private static function dekodiere(mixed $wert): mixed
{
if (is_array($wert) && isset($wert['b64']) && is_string($wert['b64'])) {
return base64_decode($wert['b64'], true) ?: '';
}
if (is_bool($wert)) {
return (int)$wert;
}
return $wert;
}
}

View file

@ -5,6 +5,7 @@ use Finanzmanager\View;
/** @var array<string,int> $zahlen */
/** @var bool $bargeldAktiv */
/** @var string $csrfToken */
include __DIR__ . '/../layout/kopf.php';
?>
@ -59,6 +60,23 @@ include __DIR__ . '/../layout/kopf.php';
</div>
</div>
<div class="kachel">
<header><h2>Backup</h2></header>
<p class="platzhalter">
Der gesamte Datenbestand als eine Datei – Backup schreibt sie, Restore
ersetzt den aktuellen Bestand vollständig durch ihren Inhalt.
</p>
<div class="knopfleiste">
<a href="/verwaltung/sicherung" class="knopf klein primaer" id="backup-erstellen">Backup</a>
<form method="post" action="/verwaltung/sicherung" enctype="multipart/form-data"
id="restore-formular" data-bestaetigen="Restore ersetzt den gesamten Datenbestand durch den Inhalt der Datei. Fortfahren?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="file" name="sicherung" id="restore-datei" accept=".gz" required>
<button type="submit" class="knopf klein" id="restore-ausfuehren">Restore</button>
</form>
</div>
</div>
<div class="kachel">
<header><h2>Online-Banking</h2></header>
<p class="platzhalter">

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Finanzmanager\Database;
use Finanzmanager\Services\Datenbanksicherung;
class DatenbanksicherungTest extends TestCase
{
protected string $testName = 'Test 55: Datenbanksicherung';
public function run(): void
{
$this->rundlauf();
$this->ungueltigeDatei();
}
private function rundlauf(): void
{
Database::execute("INSERT INTO einstellungen (schluessel, wert, bereich) VALUES ('sicherungstest', 'a', 'test')");
$vorher = (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen');
$sicherung = Datenbanksicherung::erzeugen();
$this->assert(strlen($sicherung) > 0, 'Sicherung ist nicht leer');
Database::execute("DELETE FROM einstellungen WHERE schluessel = 'sicherungstest'");
Database::execute("INSERT INTO einstellungen (schluessel, wert, bereich) VALUES ('nach_sicherung', 'b', 'test')");
$anzahl = Datenbanksicherung::wiederherstellen($sicherung);
$this->assertEquals($vorher, $anzahl['einstellungen'], 'Zeilenzahl der Tabelle einstellungen');
$this->assertEquals(1, (int)Database::fetchValue("SELECT COUNT(*) FROM einstellungen WHERE schluessel = 'sicherungstest'"),
'Gesicherte Zeile ist wieder da');
$this->assertEquals(0, (int)Database::fetchValue("SELECT COUNT(*) FROM einstellungen WHERE schluessel = 'nach_sicherung'"),
'Zeile nach der Sicherung ist verschwunden');
}
private function ungueltigeDatei(): void
{
$vorher = (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen');
foreach (['kein gzip', gzencode('kein json'), gzencode('{"kennung":"fremd"}')] as $inhalt) {
$geworfen = false;
try {
Datenbanksicherung::wiederherstellen($inhalt);
} catch (\RuntimeException) {
$geworfen = true;
}
$this->assert($geworfen, 'Ungültige Datei wird abgelehnt');
}
$this->assertEquals($vorher, (int)Database::fetchValue('SELECT COUNT(*) FROM einstellungen'),
'Bestand nach abgelehnter Datei unverändert');
}
}