diff --git a/README.md b/README.md
index 0369ba0..04faebc 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ 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.
+Erkennt das Admin-Interface vorhandene JPG-/PNG-Bestandsbilder, erscheint automatisch der Hinweis „WebP-Migration verfügbar“. Der dortige Button konvertiert die erkannten Dateien einzeln, zeigt nach jeder Datei den tatsächlichen Fortschritt an, aktualisiert ihre Verweise in `data/talks.json` und entfernt die Originale erst nach erfolgreicher Datenbankaktualisierung. Eine unterbrochene Migration kann über denselben Button sicher fortgesetzt werden.
> **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.
diff --git a/admin.php b/admin.php
index 1e879ef..838e3af 100644
--- a/admin.php
+++ b/admin.php
@@ -6,6 +6,14 @@ require_once __DIR__ . '/functions.php';
$loggedIn = isset($_SESSION['admin']) && $_SESSION['admin'] === true;
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['migration_step']) && !$loggedIn) {
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
+ http_response_code(401);
+ echo json_encode(['ok' => false, 'error' => 'Die Admin-Sitzung ist abgelaufen. Bitte Seite neu laden und anmelden.']);
+ exit;
+}
+
// Login
if (isset($_POST['action']) && $_POST['action'] === 'login') {
if (($_POST['password'] ?? '') === ADMIN_PASSWORD_PLAIN) {
@@ -30,28 +38,36 @@ if ($loggedIn) {
$_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'])) {
+ // Je Request genau ein Bestandsbild migrieren, damit das Frontend echten
+ // Fortschritt anzeigen kann und kein langer PHP-Request am Gateway scheitert.
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['migration_step'])) {
+ header('Content-Type: application/json; charset=utf-8');
+ header('Cache-Control: no-store');
$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();
- }
+ http_response_code(403);
+ echo json_encode(['ok' => false, 'error' => 'Die Migrationsanfrage ist abgelaufen. Bitte Seite neu laden.']);
+ exit;
}
+
+ session_write_close();
+ try {
+ $result = migrate_next_upload_to_webp();
+ echo json_encode(['ok' => true, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+ } catch (Throwable $e) {
+ http_response_code(422);
+ echo json_encode(
+ ['ok' => false, 'error' => 'Migration fehlgeschlagen: ' . $e->getMessage()],
+ JSON_UNESCAPED_UNICODE
+ );
+ }
+ exit;
}
// CREATE/UPDATE
- elseif ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_event'])) {
$events = load_events();
$id = $_POST['id'] ?: 'evt_' . bin2hex(random_bytes(6));
$title = sanitize_text($_POST['title'] ?? '');
@@ -226,36 +242,43 @@ if ($loggedIn) {
- = (int)$migrationNotice['processed'] ?> Bild(er) wurden konvertiert und
- = (int)$migrationNotice['references'] ?> Datenbank-Verweis(e) aktualisiert.
- = count($migrationNotice['remaining']) ?> Originaldatei(en) konnten nicht entfernt werden und werden erneut angeboten.
-
-
-
- Migration abgeschlossen: = (int)$migrationNotice['processed'] ?> Bild(er) konvertiert und
- = (int)$migrationNotice['references'] ?> Datenbank-Verweis(e) aktualisiert.
-
-
-
-
-
-
-
WebP-Migration verfügbar
-
- = count($nonWebpUploads) ?> vorhandene(s) Upload-Bild(er) sind noch nicht als WebP gespeichert.
- Die Veranstaltungseinträge werden automatisch aktualisiert.
+
+
+
+
WebP-Migration verfügbar
+
+ = count($nonWebpUploads) ?> vorhandene(s) Upload-Bild(er) sind noch nicht als WebP gespeichert.
+ Die Veranstaltungseinträge werden automatisch aktualisiert.
+
-
-
+
+
+
+
Migration wird vorbereitet …
+
@@ -491,6 +514,106 @@ if ($loggedIn) {
toggle.addEventListener('change', apply);
apply();
})();
+
+ // Bestandsmigration: ein Bild pro Request, damit der Fortschritt sichtbar
+ // bleibt und die Migration nach Abbruch gefahrlos fortgesetzt werden kann.
+ (function () {
+ const panel = document.getElementById('webp-migration-panel');
+ const button = document.getElementById('webp-migration-start');
+ const wrap = document.getElementById('webp-migration-progress-wrap');
+ const progress = document.getElementById('webp-migration-progress');
+ const progressBox = progress ? progress.parentElement : null;
+ const status = document.getElementById('webp-migration-status');
+ const summary = document.getElementById('webp-migration-summary');
+ if (!panel || !button || !wrap || !progress || !progressBox || !status || !summary) return;
+
+ let total = Number.parseInt(panel.dataset.total || '0', 10);
+ let completed = 0;
+ let running = false;
+
+ const setProgress = (percent) => {
+ const bounded = Math.max(0, Math.min(100, percent));
+ progress.style.width = `${bounded}%`;
+ progress.textContent = `${bounded}%`;
+ progressBox.setAttribute('aria-valuenow', String(bounded));
+ };
+
+ const finish = () => {
+ setProgress(100);
+ progress.classList.remove('progress-bar-animated');
+ panel.classList.remove('alert-warning', 'alert-danger');
+ panel.classList.add('alert-success');
+ summary.textContent = 'WebP-Migration abgeschlossen.';
+ status.textContent = 'Alle Bestandsbilder wurden konvertiert und ihre Datenbank-Verweise aktualisiert.';
+ button.classList.add('d-none');
+ running = false;
+ };
+
+ const runMigration = async () => {
+ if (running) return;
+ if (completed === 0 && !window.confirm('Alle erkannten Bestandsbilder jetzt nach WebP konvertieren?')) return;
+
+ running = true;
+ button.disabled = true;
+ button.textContent = 'Migration läuft …';
+ wrap.classList.remove('d-none');
+ panel.classList.remove('alert-danger');
+ panel.classList.add('alert-warning');
+ progress.classList.add('progress-bar-animated');
+
+ try {
+ while (true) {
+ status.textContent = `Konvertiere Bild ${Math.min(completed + 1, total)} von ${total} …`;
+ const body = new URLSearchParams({
+ migration_step: '1',
+ migration_csrf: panel.dataset.csrf || ''
+ });
+ const response = await fetch('admin.php', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'},
+ body: body.toString()
+ });
+ const responseText = await response.text();
+ let payload;
+ try {
+ payload = JSON.parse(responseText);
+ } catch (error) {
+ throw new Error(`Ungültige Serverantwort (HTTP ${response.status}).`);
+ }
+ if (!response.ok || !payload.ok) {
+ throw new Error(payload.error || `Serverfehler (HTTP ${response.status}).`);
+ }
+
+ const result = payload.result;
+ if (result.processed === 0 || result.remaining_count === 0) {
+ finish();
+ return;
+ }
+
+ completed += result.processed;
+ total = Math.max(total, completed + result.remaining_count);
+ setProgress(Math.round((completed / total) * 100));
+ status.textContent = `${result.source} wurde konvertiert. Noch ${result.remaining_count} Bild(er).`;
+
+ if (result.deletion_failed) {
+ throw new Error(`Die Originaldatei ${result.source} konnte nicht entfernt werden.`);
+ }
+ }
+ } catch (error) {
+ running = false;
+ button.disabled = false;
+ button.textContent = 'Migration fortsetzen';
+ progress.classList.remove('progress-bar-animated');
+ panel.classList.remove('alert-warning');
+ panel.classList.add('alert-danger');
+ summary.textContent = 'Die Migration wurde unterbrochen.';
+ status.textContent = error instanceof Error ? error.message : 'Unbekannter Fehler.';
+ }
+ };
+
+ button.addEventListener('click', runMigration);
+ })();