Files
tsv08kulmbach-website/bin/log-rotate.php
fs 05c64c8f12 Takt über public/cron.php: KAS-Cronjobs können nur URLs
Korrektur meiner eigenen Annahme von heute früh. Der Tarif hat SSH und
Cronjobs, aber das KAS-Formular kennt nur ein Feld "Protokoll / Pfad" mit
https:// — ein `php bin/…` ist dort nicht eintragbar. Der Endpoint, den ich
vorhin stillgelegt hatte, ist damit wieder der einzige Takt-Geber, und zwar in
einer besseren Form als geplant: Aufrufer ist der Hoster selbst, der Schlüssel
verlässt den Webspace nie, kein Dritter ist beteiligt.

Dritter Job 'logrotate' in der Whitelist, denn ohne ihn liefe die Logpflege nie
automatisch und die 2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Er passt in
denselben Rahmen wie die Syncs: kein Parameter aus der URL, keine Verbindung nach
außen, schreibt nur in storage/logs. bin/log-rotate.php trägt dafür denselben
Riegel wie die Syncs (CRON_HTTP) und require_once. Eine vierte Ausnahme braucht
wieder denselben Aufwand an Begründung.

Geprüft: falscher Schlüssel 403, unbekannter Job 403, gültiger Aufruf 204 mit
leerem Body, sofortige Wiederholung 204 plus WARN, Logdateien unversehrt.

Zeitraster angepasst, weil das KAS Wochentage nicht mit Halbstunden kombiniert:
Matchcenter stündlich statt 2x/Tag plus Wochenende, Instagram als zwei
Tageseinträge, Logpflege täglich nachts. Die Abwägung steht in
config.example.php und docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:16:12 +02:00

136 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Logpflege für storage/logs/ — NUR CLI, per Cron 1×/Woche.
*
* Zwei Aufgaben:
* 1. Rotation nach Größe: wird eine Datei größer als das Limit, wandert sie nach
* <name>.log.1 (eine vorhandene .1 wird ersetzt) und das aktive Log startet leer.
* Es bleiben also höchstens zwei Generationen pro Log liegen.
* 2. Aufbewahrungsfrist: Einträge, die älter als --keep-days sind, werden aus den
* aktiven Logs entfernt; rotierte .1-Dateien jenseits der Frist gelöscht. Damit
* gibt es eine echte Löschfrist statt nur einer Größengrenze — auf einer ruhigen
* Vereinsseite läge ein Eintrag sonst jahrelang herum.
*
* Aufruf: php bin/log-rotate.php [--max-mb=2] [--keep-days=90] [-v]
*/
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// definiert. Nötig, weil die KAS-Cronjobs ausschließlich URLs aufrufen können und
// dieses Skript sonst nie automatisch liefe (Stand 31.07.2026).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
exit(1);
}
// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';
$maxMb = 2.0;
$keepDays = 90;
$verbose = in_array('-v', $argv, true);
foreach ($argv as $arg) {
if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) {
$maxMb = (float) $m[1];
} elseif (preg_match('/^--keep-days=(\d+)$/', $arg, $m) === 1) {
$keepDays = max(1, (int) $m[1]);
}
}
$limit = (int) ($maxMb * 1024 * 1024);
$cutoff = time() - $keepDays * 86400;
$logDir = STORAGE_PATH . '/logs';
/**
* Zeitstempel am Zeilenanfang: `[ISO]` (log_write), `[27-Jul-2026 08:11:45 UTC]`
* (PHP selbst) oder ein nackter ISO-Stempel. Null = keiner.
*/
$stampOf = static function (string $line): ?int {
if (preg_match('/^\[([^\]]+)\]/', $line, $m) === 1 || preg_match('/^(\d{4}-\d{2}-\d{2}\S*)/', $line, $m) === 1) {
$ts = strtotime($m[1]);
return $ts === false ? null : $ts;
}
return null;
};
/**
* Einträge vor $cutoff entfernen. Zeilen ohne eigenen Zeitstempel (z. B. PHP-Stacktraces)
* gehören zum vorherigen Eintrag und teilen dessen Schicksal. Geschrieben wird über eine
* temporäre Datei + rename; ein Append genau in diesem Moment ginge verloren — bei einem
* wöchentlichen Lauf nachts ein akzeptables Risiko, dafür bleibt die Datei immer konsistent.
*/
$prune = static function (string $file) use ($cutoff, $stampOf): int {
$raw = (string) @file_get_contents($file);
if ($raw === '') {
return 0;
}
$keep = [];
$dropping = false;
$removed = 0;
foreach (explode("\n", $raw) as $line) {
$ts = $stampOf($line);
if ($ts !== null) {
$dropping = $ts < $cutoff;
}
if ($dropping) {
if (trim($line) !== '') {
$removed++;
}
continue;
}
$keep[] = $line;
}
if ($removed === 0) {
return 0;
}
$tmp = $file . '.tmp';
if (@file_put_contents($tmp, implode("\n", $keep)) === false || !@rename($tmp, $file)) {
@unlink($tmp);
return 0;
}
@chmod($file, 0664);
return $removed;
};
$rotated = 0;
$pruned = 0;
$deleted = 0;
foreach (glob($logDir . '/*.log') ?: [] as $log) {
$name = basename($log);
// 1. Alte Einträge raus (vor der Größenprüfung — spart oft die Rotation).
$removed = $prune($log);
if ($removed > 0) {
$pruned += $removed;
if ($verbose) {
echo "gekürzt: {$name} ({$removed} Einträge älter als {$keepDays} Tage)\n";
}
}
// 2. Rotation nach Größe.
$size = (int) @filesize($log);
if ($size > $limit) {
if (@rename($log, $log . '.1')) {
@touch($log);
@chmod($log, 0664);
echo "rotiert: {$name} (" . round($size / 1048576, 1) . " MB)\n";
$rotated++;
} else {
fwrite(STDERR, "Rotation fehlgeschlagen: {$name}\n");
}
}
}
// 3. Rotierte Generationen jenseits der Frist löschen.
foreach (glob($logDir . '/*.log.1') ?: [] as $old) {
if ((int) @filemtime($old) < $cutoff && @unlink($old)) {
echo 'gelöscht: ' . basename($old) . " (älter als {$keepDays} Tage)\n";
$deleted++;
}
}
echo $rotated === 0 && $pruned === 0 && $deleted === 0
? "Nichts zu tun (alle Logs unter {$maxMb} MB, keine Einträge älter als {$keepDays} Tage).\n"
: "Fertig: {$rotated} rotiert, {$pruned} alte Einträge entfernt, {$deleted} Altdatei(en) gelöscht.\n";