Emergency layer logs fatal errors to var/log/notfall.log and answers 503. The system page checks for a new release on load, shows its notes, runs backup, download, switch, migration and health check in one flow with live progress, and switches back automatically when the new version fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.6 KiB
PHP
124 lines
5.6 KiB
PHP
<?php
|
||
|
||
namespace App\Ops;
|
||
|
||
/**
|
||
* Die letzte Sicherung, wenn die Anwendung nicht einmal mehr eine Fehlerseite zustande bringt
|
||
* (ADR 113).
|
||
*
|
||
* Kantarci Reisen am 09.10.2026: Datenbank per FTP ersetzt, danach antwortete jede Adresse mit
|
||
* einem leeren 500er - für Besucher eine weiße Seite, für den Betreiber kein Hinweis, warum. Der
|
||
* Grund stand nur im Fehlerprotokoll des Hostings.
|
||
*
|
||
* Diese Klasse hängt an nichts aus dem Framework: kein Container, keine Datenbank, keine
|
||
* Vorlage. Sie schreibt den Grund nach var/log/notfall.log (per FTP lesbar, außerhalb von
|
||
* public/) und zeigt Besuchern eine schlichte Wartungsseite mit 503 - für Suchmaschinen heißt das
|
||
* „gleich wieder da“, nicht „kaputt“. Details stehen nie auf der Seite.
|
||
*/
|
||
final class Notfall
|
||
{
|
||
public const PROTOKOLL = 'var/log/notfall.log';
|
||
/** Größer wird die Datei nicht; die ältere Hälfte wandert nach notfall.log.1. */
|
||
private const GRENZE = 1048576;
|
||
/** Fehlerarten, nach denen PHP abbricht - nur die gehören auf die Notfallseite. */
|
||
private const ABBRUCH = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
|
||
|
||
public const KOPFZEILEN = [
|
||
'Content-Type' => 'text/html; charset=UTF-8',
|
||
'Retry-After' => '300',
|
||
'Cache-Control' => 'no-store',
|
||
'X-Robots-Tag' => 'noindex',
|
||
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
|
||
];
|
||
|
||
private static ?string $wurzel = null;
|
||
private static bool $gezeigt = false;
|
||
|
||
/** Einmal ganz am Anfang von public/index.php. */
|
||
public static function einschalten(string $wurzel): void
|
||
{
|
||
self::$wurzel = $wurzel;
|
||
register_shutdown_function(static function (): void {
|
||
$fehler = error_get_last();
|
||
if (null === $fehler || !in_array($fehler['type'], self::ABBRUCH, true)) {
|
||
return;
|
||
}
|
||
self::melden(sprintf('PHP-Abbruch: %s in %s:%d', $fehler['message'], $fehler['file'], $fehler['line']));
|
||
self::seite();
|
||
// Danach soll nichts mehr ausgeben - auch nicht die Fehlerseite des Frameworks, das
|
||
// sich ebenfalls fürs Herunterfahren angemeldet hat. exit in einer Shutdown-Funktion
|
||
// beendet die übrigen.
|
||
exit;
|
||
});
|
||
}
|
||
|
||
/** Eine Ausnahme, die aus dem Framework herausgefallen ist. */
|
||
public static function ausnahme(\Throwable $fehler, string $adresse = ''): void
|
||
{
|
||
self::melden(self::beschreiben($fehler, $adresse));
|
||
self::seite();
|
||
}
|
||
|
||
/** Ein Fehler, den das Framework abgefangen und als 500 beantwortet hat - nur protokollieren. */
|
||
public static function protokollieren(\Throwable $fehler, string $adresse = ''): void
|
||
{
|
||
self::melden(self::beschreiben($fehler, $adresse));
|
||
}
|
||
|
||
public static function beschreiben(\Throwable $fehler, string $adresse = ''): string
|
||
{
|
||
$zeile = sprintf('%s: %s in %s:%d', $fehler::class, $fehler->getMessage(), $fehler->getFile(), $fehler->getLine());
|
||
// Die eigentliche Ursache steckt oft eine Ebene tiefer, etwa das SQLite-Problem unter
|
||
// einer Doctrine-Ausnahme.
|
||
for ($tiefer = $fehler->getPrevious(), $n = 0; null !== $tiefer && $n < 3; $tiefer = $tiefer->getPrevious(), ++$n) {
|
||
$zeile .= sprintf(' | Ursache: %s: %s', $tiefer::class, $tiefer->getMessage());
|
||
}
|
||
|
||
return ('' === $adresse ? '' : $adresse.' – ').$zeile;
|
||
}
|
||
|
||
private static function melden(string $text): void
|
||
{
|
||
$wurzel = self::$wurzel ?? dirname(__DIR__, 2);
|
||
$datei = $wurzel.'/'.self::PROTOKOLL;
|
||
if (!is_dir(dirname($datei))) {
|
||
@mkdir(dirname($datei), 0o775, true);
|
||
}
|
||
if (is_file($datei) && filesize($datei) > self::GRENZE) {
|
||
@rename($datei, $datei.'.1');
|
||
}
|
||
// Zeilenumbrüche aus Meldungen würden das Protokoll zerreißen; eine Zeile je Ereignis.
|
||
$text = str_replace(["\r", "\n"], ' ', $text);
|
||
@file_put_contents($datei, sprintf("[%s UTC] %s\n", gmdate('Y-m-d H:i:s'), mb_substr($text, 0, 4000)), FILE_APPEND | LOCK_EX);
|
||
}
|
||
|
||
/** Die Seite für Besucher. Ohne Details, ohne Abhängigkeiten, ohne fremde Quellen. */
|
||
private static function seite(): void
|
||
{
|
||
if (self::$gezeigt || PHP_SAPI === 'cli') {
|
||
return;
|
||
}
|
||
self::$gezeigt = true;
|
||
while (ob_get_level() > 0) {
|
||
@ob_end_clean();
|
||
}
|
||
if (!headers_sent()) {
|
||
http_response_code(503);
|
||
foreach (self::KOPFZEILEN as $name => $wert) {
|
||
header($name.': '.$wert);
|
||
}
|
||
}
|
||
echo self::html();
|
||
}
|
||
|
||
/** Die Wartungsseite als Text, auch für den Fehler-Listener im Framework. */
|
||
public static function html(): string
|
||
{
|
||
return '<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
|
||
.'<title>Gleich wieder da</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:17px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;background:#f4f6f5;color:#1f2d27}'
|
||
.'main{max-width:460px;padding:32px;text-align:center}h1{font-size:26px;line-height:1.25;margin:0 0 12px}p{margin:0;color:#46514c}</style></head>'
|
||
.'<body><main><h1>Die Website ist gleich wieder da.</h1><p>Gerade wird etwas erneuert. Bitte versuche es in ein paar Minuten noch einmal.</p>'
|
||
.'<p lang="en" style="margin-top:14px;font-size:14px">This website will be back shortly.</p></main></body></html>';
|
||
}
|
||
}
|