diff --git a/README.md b/README.md index c40954f..0369ba0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Ein kleines, dateibasiertes PHP-CMS für die Website der Walschleber KulTour. Ve - JSON-LD-Ausgabe für Veranstaltungssuchmaschinen - Dateibasierte Speicherung in `data/talks.json` - Automatische Konvertierung von JPG-, PNG- und WebP-Uploads nach WebP +- Admin-Migration für bereits vorhandene Bilder, die noch nicht als WebP gespeichert sind - Keine Composer-, Node.js- oder Build-Abhängigkeiten ## Voraussetzungen @@ -116,6 +117,8 @@ Diese Pfade enthalten den individuellen Datenbestand einer Installation: Beide Bereiche werden von Git ignoriert. Ein normaler `git pull` überschreibt oder löscht sie daher nach der Umstellung nicht. `uploads/.htaccess` und die `.gitignore`-Dateien bleiben Bestandteil des Repositorys. +Erkennt das Admin-Interface vorhandene JPG-/PNG-Bestandsbilder, erscheint automatisch der Hinweis „WebP-Migration verfügbar“. Der dortige Button konvertiert die erkannten Dateien, aktualisiert ihre Verweise in `data/talks.json` und entfernt die Originale erst nach erfolgreicher Datenbankaktualisierung. + > **Wichtig:** Auf einem Produktivserver niemals `git clean -fdx` ausführen. Der Parameter `-x` bezieht ignorierte Dateien ein und würde dadurch die JSON-Datenbank und Uploads löschen. ## Datensicherung @@ -136,15 +139,15 @@ Die Sicherung enthält damit Datenbank, Bilder und die installationsspezifische ### Reguläres Update -Zuerst den Arbeitsstand kontrollieren und eine Sicherung wie oben beschrieben erstellen: +Für Updates steht ein sicherer Updater bereit. Er sichert `data/`, `uploads/` und `config.php` außerhalb des Checkouts, aktualisiert ausschließlich per Fast-Forward und stellt die persistenten Dateien anschließend wieder her: ```bash cd /var/www/walschleber-kultour -git status --short -git fetch origin -git pull --ff-only origin main +bash scripts/update-server.sh . ``` +Der ausgegebene Backup-Ordner wird absichtlich nicht automatisch gelöscht. + Anschließend Schreibrechte und PHP-Syntax prüfen: ```bash @@ -159,7 +162,7 @@ Lokale Änderungen an Anwendungsdateien können ein Update blockieren. Sie sollt ### Lokale Änderungen an `config.php` -`config.php` gehört zum Repository, enthält aber installationsspezifische Werte. Die Datei deshalb immer zusammen mit den Daten sichern. Falls `git pull` meldet, dass lokale Änderungen an `config.php` überschrieben würden: +`config.php` gehört zum Repository, enthält aber installationsspezifische Werte. `scripts/update-server.sh` sichert und erhält diese Datei automatisch. Bei einem manuellen Update muss sie vor dem Pull gesichert werden: ```bash config_backup="../kultour-config-$(date +%Y%m%d-%H%M%S).php" @@ -174,30 +177,23 @@ Danach die Werte aus der gesicherten Datei manuell in die aktuelle `config.php` ## Einmaliges Update von einer älteren Version -In älteren Versionen wurden `data/talks.json` und vorhandene Uploads noch von Git verfolgt. Beim **ersten Update auf die bereinigte Version** müssen diese Daten deshalb gesichert und nach dem Pull wiederhergestellt werden. +In älteren Versionen wurden `data/talks.json` und vorhandene Uploads noch von Git verfolgt. Ein normales `git pull` würde diese Dateien beim ersten Wechsel auf die bereinigte Version entfernen. + +> **Vor diesem einmaligen Wechsel kein normales `git pull` ausführen.** Den aktuellen Updater zunächst außerhalb des alten Checkouts herunterladen und von dort starten: ```bash +curl -fL \ + https://git.mrblake.cc/MrBlake/Walschleber-Kultour-CMS/raw/branch/main/scripts/update-server.sh \ + -o /tmp/kultour-update-server.sh +chmod 700 /tmp/kultour-update-server.sh +bash /tmp/kultour-update-server.sh /var/www/walschleber-kultour + cd /var/www/walschleber-kultour - -install_backup="../kultour-migration-$(date +%Y%m%d-%H%M%S)" -mkdir -p "$install_backup" -cp -a data uploads config.php "$install_backup/" - -# Nur die früher getrackten Persistenzpfade auf den alten Git-Stand setzen. -git restore --source=HEAD --staged --worktree -- data uploads - -git pull --ff-only origin main - -# Den gesicherten Datenbestand wiederherstellen. -mkdir -p data uploads -cp -a "$install_backup/data/." data/ -cp -a "$install_backup/uploads/." uploads/ - chown -R www-data:www-data data uploads chmod 775 data uploads ``` -Falls das Update wegen einer lokal geänderten `config.php` stoppt, zusätzlich die Schritte aus „Lokale Änderungen an `config.php`“ durchführen. Nach dieser einmaligen Migration erscheinen Änderungen an der Datenbank und neue Uploads nicht mehr in `git status`. +Bei einem privaten Repository kann der Updater alternativ im angemeldeten Browser heruntergeladen und als Datei nach `/tmp/kultour-update-server.sh` übertragen werden. Nach dieser einmaligen Migration erscheinen Änderungen an der Datenbank und neue Uploads nicht mehr in `git status`. ## Wiederherstellung aus einem Backup diff --git a/admin.php b/admin.php index 5a11a86..1e879ef 100644 --- a/admin.php +++ b/admin.php @@ -26,8 +26,32 @@ if (isset($_GET['logout'])) { } if ($loggedIn) { + if (empty($_SESSION['migration_csrf'])) { + $_SESSION['migration_csrf'] = bin2hex(random_bytes(32)); + } + $migrationCsrf = (string)$_SESSION['migration_csrf']; + $migrationNotice = $_SESSION['migration_notice'] ?? null; + $migrationError = null; + unset($_SESSION['migration_notice']); + + // BESTANDS-UPLOADS NACH WEBP MIGRIEREN + if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['migrate_uploads'])) { + $submittedToken = (string)($_POST['migration_csrf'] ?? ''); + if (!hash_equals($migrationCsrf, $submittedToken)) { + $migrationError = 'Die Migrationsanfrage ist abgelaufen. Bitte Seite neu laden und erneut versuchen.'; + } else { + try { + $_SESSION['migration_notice'] = migrate_uploads_to_webp(); + header('Location: admin.php?migration=done'); + exit; + } catch (Throwable $e) { + $migrationError = 'Migration fehlgeschlagen: ' . $e->getMessage(); + } + } + } + // CREATE/UPDATE - if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) { + elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) { $events = load_events(); $id = $_POST['id'] ?: 'evt_' . bin2hex(random_bytes(6)); $title = sanitize_text($_POST['title'] ?? ''); @@ -121,6 +145,13 @@ if ($loggedIn) { if ($e['id'] === $id) { $editEvent = $e; break; } } } + + try { + $nonWebpUploads = find_non_webp_uploads(); + } catch (Throwable $e) { + $nonWebpUploads = []; + $migrationError = 'Upload-Prüfung fehlgeschlagen: ' . $e->getMessage(); + } } ?> @@ -191,6 +222,43 @@ if ($loggedIn) { Gelöscht + + + + + + + + + + + + + + + +
diff --git a/functions.php b/functions.php index 4a93e6e..7b29bf0 100644 --- a/functions.php +++ b/functions.php @@ -3,6 +3,11 @@ declare(strict_types=1); require_once __DIR__ . '/config.php'; +// Erlaubt ein sicheres Update mit einer älteren, serverspezifischen config.php. +if (!defined('WEBP_QUALITY')) { + define('WEBP_QUALITY', 85); +} + function ensure_storage(): void { if (!is_dir(dirname(DATA_FILE))) { @mkdir(dirname(DATA_FILE), 0775, true); @@ -24,16 +29,42 @@ function load_events(): array { function save_events(array $events): void { ensure_storage(); + $json = json_encode( + ['events' => array_values($events)], + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR + ); $fp = fopen(DATA_FILE, 'c+'); if ($fp === false) { throw new RuntimeException('Kann Datei nicht öffnen: ' . DATA_FILE); } - flock($fp, LOCK_EX); - ftruncate($fp, 0); - fwrite($fp, json_encode(['events' => array_values($events)], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); - fflush($fp); - flock($fp, LOCK_UN); - fclose($fp); + + if (!flock($fp, LOCK_EX)) { + fclose($fp); + throw new RuntimeException('Kann Datenbank nicht sperren: ' . DATA_FILE); + } + + try { + if (!ftruncate($fp, 0) || !rewind($fp)) { + throw new RuntimeException('Kann Datenbank nicht leeren: ' . DATA_FILE); + } + + $length = strlen($json); + $offset = 0; + while ($offset < $length) { + $written = fwrite($fp, substr($json, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Kann Datenbank nicht schreiben: ' . DATA_FILE); + } + $offset += $written; + } + + if (!fflush($fp)) { + throw new RuntimeException('Kann Datenbank nicht speichern: ' . DATA_FILE); + } + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } } function by_datetime_asc(array $a, array $b): int { @@ -140,6 +171,206 @@ function handle_image_upload(?array $file): ?string { return '/uploads/'.$basename; // Pfad relativ zur Webroot } +/** + * Findet vorhandene Bilddateien im Upload-Ordner, deren Name nicht auf .webp endet. + */ +function find_non_webp_uploads(): array { + ensure_storage(); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + throw new RuntimeException('Die vorhandenen Uploads konnten nicht geprüft werden.'); + } + + $uploads = []; + try { + foreach (new DirectoryIterator(UPLOAD_DIR) as $file) { + if (!$file->isFile() || $file->isDot()) { + continue; + } + + $name = $file->getFilename(); + if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'webp') { + continue; + } + + $mime = finfo_file($finfo, $file->getPathname()); + if (!is_string($mime) || !in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { + continue; + } + if (@getimagesize($file->getPathname()) === false) { + continue; + } + + $uploads[] = [ + 'name' => $name, + 'path' => $file->getPathname(), + 'mime' => $mime, + 'size' => $file->getSize(), + ]; + } + } finally { + finfo_close($finfo); + } + + usort($uploads, static function (array $left, array $right): int { + return strcasecmp($left['name'], $right['name']); + }); + + return $uploads; +} + +function load_events_for_migration(): array { + ensure_storage(); + $raw = file_get_contents(DATA_FILE); + if ($raw === false) { + throw new RuntimeException('Die Datenbank konnte nicht gelesen werden.'); + } + + try { + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (Throwable $exception) { + throw new RuntimeException('Die Datenbank enthält ungültiges JSON. Migration wurde abgebrochen.', 0, $exception); + } + + if (!is_array($data) || !isset($data['events']) || !is_array($data['events'])) { + throw new RuntimeException('Die Datenbank hat ein ungültiges Format. Migration wurde abgebrochen.'); + } + + return array_values($data['events']); +} + +/** + * Konvertiert Bestandsbilder nach WebP und aktualisiert anschließend ihre JSON-Verweise. + * Originaldateien werden erst nach einer erfolgreichen Datenbankaktualisierung entfernt. + */ +function migrate_uploads_to_webp(): array { + $uploads = find_non_webp_uploads(); + if (!$uploads) { + return ['processed' => 0, 'references' => 0, 'removed' => 0, 'remaining' => []]; + } + + $uploadDir = rtrim(UPLOAD_DIR, '/\\'); + $createdFiles = []; + $temporaryFiles = []; + $mapping = []; + $events = load_events_for_migration(); + $databaseBackup = null; + + try { + foreach ($uploads as $upload) { + $contentHash = hash_file('sha256', $upload['path']); + if ($contentHash === false) { + throw new RuntimeException('Prüfsumme fehlgeschlagen: ' . $upload['name']); + } + + $stem = pathinfo($upload['name'], PATHINFO_FILENAME); + $stem = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $stem); + $stem = trim((string)$stem, '-_'); + if ($stem === '') { + $stem = 'legacy-image'; + } + $stem = substr($stem, 0, 160); + + $targetName = $stem . '-converted-' . substr($contentHash, 0, 10) . '.webp'; + $targetPath = $uploadDir . '/' . $targetName; + + if (is_file($targetPath)) { + $targetMime = mime_content_type($targetPath); + if ($targetMime !== 'image/webp' || @getimagesize($targetPath) === false) { + throw new RuntimeException('Vorhandene Zieldatei ist kein gültiges WebP: ' . $targetName); + } + } else { + $temporaryPath = $uploadDir . '/.' . $targetName . '.' . bin2hex(random_bytes(4)) . '.tmp'; + $temporaryFiles[] = $temporaryPath; + convert_image_to_webp($upload['path'], $upload['mime'], $temporaryPath, WEBP_QUALITY); + + if (mime_content_type($temporaryPath) !== 'image/webp' || @getimagesize($temporaryPath) === false) { + throw new RuntimeException('Konvertierung konnte nicht validiert werden: ' . $upload['name']); + } + if (!@rename($temporaryPath, $targetPath)) { + throw new RuntimeException('WebP-Datei konnte nicht gespeichert werden: ' . $targetName); + } + + $temporaryFiles = array_values(array_filter( + $temporaryFiles, + static function (string $path) use ($temporaryPath): bool { + return $path !== $temporaryPath; + } + )); + $createdFiles[] = $targetPath; + } + + $mapping[$upload['name']] = '/uploads/' . $targetName; + } + + $updatedReferences = 0; + foreach ($events as &$event) { + $image = (string)($event['image'] ?? ''); + if ($image === '') { + continue; + } + + $path = parse_url($image, PHP_URL_PATH); + $sourceName = rawurldecode(basename(is_string($path) ? $path : $image)); + if (isset($mapping[$sourceName])) { + $event['image'] = $mapping[$sourceName]; + $updatedReferences++; + } + } + unset($event); + + if ($updatedReferences > 0) { + $databaseBackup = DATA_FILE . '.migration-' . bin2hex(random_bytes(6)) . '.bak'; + if (!@copy(DATA_FILE, $databaseBackup)) { + throw new RuntimeException('Die Datenbank konnte vor der Migration nicht gesichert werden.'); + } + + try { + save_events($events); + } catch (Throwable $exception) { + if (!@copy($databaseBackup, DATA_FILE)) { + $backupToKeep = $databaseBackup; + $databaseBackup = null; + throw new RuntimeException( + 'Datenbankaktualisierung und Wiederherstellung sind fehlgeschlagen. Backup: ' . $backupToKeep, + 0, + $exception + ); + } + throw $exception; + } + } + } catch (Throwable $exception) { + foreach (array_merge($temporaryFiles, $createdFiles) as $path) { + if (is_file($path)) { + @unlink($path); + } + } + throw $exception; + } finally { + if ($databaseBackup !== null && is_file($databaseBackup)) { + @unlink($databaseBackup); + } + } + + $removed = 0; + $remaining = []; + foreach ($uploads as $upload) { + if (@unlink($upload['path'])) { + $removed++; + } else { + $remaining[] = $upload['name']; + } + } + + return [ + 'processed' => count($uploads), + 'references' => $updatedReferences, + 'removed' => $removed, + 'remaining' => $remaining, + ]; +} + /** * Konvertiert ein geprüftes JPG-, PNG- oder WebP-Bild in eine WebP-Datei. * Unterstützt GD mit WebP-Support sowie Imagick als Alternative. diff --git a/scripts/update-server.sh b/scripts/update-server.sh new file mode 100755 index 0000000..c25b102 --- /dev/null +++ b/scripts/update-server.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Das Skript außerhalb des Checkouts weiterführen, damit ein Pull seine laufende +# Version nicht verändern kann. +if [[ "${KULTOUR_UPDATE_REEXEC:-0}" != "1" ]]; then + stable_script="$(mktemp "${TMPDIR:-/tmp}/kultour-update.XXXXXX")" + cp -- "$0" "$stable_script" + chmod 700 "$stable_script" + export KULTOUR_UPDATE_REEXEC=1 + export KULTOUR_UPDATE_TEMP_SCRIPT="$stable_script" + exec bash "$stable_script" "$@" +fi + +repository_input="${1:-.}" +cd "$repository_input" +repository_root="$(git rev-parse --show-toplevel)" +cd "$repository_root" + +repository_parent="$(dirname "$repository_root")" +update_backup="$(mktemp -d "${repository_parent}/kultour-update-backup.XXXXXX")" +restore_required=0 + +cleanup_temp_script() { + if [[ -n "${KULTOUR_UPDATE_TEMP_SCRIPT:-}" && -f "$KULTOUR_UPDATE_TEMP_SCRIPT" ]]; then + rm -f -- "$KULTOUR_UPDATE_TEMP_SCRIPT" + fi +} + +restore_persistence() { + mkdir -p "$repository_root/data" "$repository_root/uploads" + cp -a -- "$update_backup/data/." "$repository_root/data/" + cp -a -- "$update_backup/uploads/." "$repository_root/uploads/" + cp -a -- "$update_backup/config.php" "$repository_root/config.php" +} + +handle_failure() { + status=$? + trap - ERR INT TERM + if [[ "$restore_required" == "1" ]]; then + echo "Update fehlgeschlagen – stelle Persistenz aus dem Backup wieder her ..." >&2 + restore_persistence || true + fi + echo "Backup bleibt erhalten: $update_backup" >&2 + cleanup_temp_script + exit "$status" +} + +trap handle_failure ERR INT TERM + +for required_path in data uploads config.php; do + if [[ ! -e "$repository_root/$required_path" ]]; then + echo "Erforderlicher Pfad fehlt: $repository_root/$required_path" >&2 + exit 1 + fi +done + +cp -a -- "$repository_root/data" "$update_backup/data" +cp -a -- "$repository_root/uploads" "$update_backup/uploads" +cp -a -- "$repository_root/config.php" "$update_backup/config.php" +restore_required=1 + +echo "Persistenz gesichert: $update_backup" +echo "Aktueller Arbeitsstand:" +git status --short + +# In älteren Versionen waren Datenbank und Bilder noch getrackt. Nur diese +# Pfade sowie die lokale Konfiguration werden für den Pull auf HEAD gesetzt. +git restore --source=HEAD --staged --worktree -- data uploads config.php +git fetch origin main +git merge --ff-only origin/main + +restore_persistence +restore_required=0 +trap - ERR INT TERM +cleanup_temp_script + +echo "Update abgeschlossen. Datenbank, Uploads und config.php wurden wiederhergestellt." +echo "Sicherheits-Backup bleibt erhalten: $update_backup" +git status --short --branch