Files
MrBlake 186b4a27c2 Zeige Fortschritt bei der WebP-Migration
Teilt die Bestandsmigration in einen sicheren Request pro Bild auf, zeigt den tatsächlichen Fortschritt im Adminbereich und erlaubt die Wiederaufnahme nach Gateway- oder Netzwerkabbrüchen.
2026-08-22 10:42:02 +02:00

620 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// admin.php
declare(strict_types=1);
session_start();
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) {
$_SESSION['admin'] = true;
header('Location: admin.php');
exit;
} else {
$error = 'Falsches Passwort.';
}
}
// Logout
if (isset($_GET['logout'])) {
$_SESSION = [];
session_destroy();
header('Location: admin.php');
exit;
}
if ($loggedIn) {
if (empty($_SESSION['migration_csrf'])) {
$_SESSION['migration_csrf'] = bin2hex(random_bytes(32));
}
$migrationCsrf = (string)$_SESSION['migration_csrf'];
$migrationError = null;
// 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)) {
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
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'] ?? '');
$speaker = sanitize_text($_POST['speaker'] ?? '');
$date = sanitize_text($_POST['date'] ?? '');
$time = sanitize_text($_POST['time'] ?? '19:30');
$venue_name = sanitize_text($_POST['venue_name'] ?? '');
$venue_address = sanitize_text($_POST['venue_address'] ?? '');
$price = sanitize_text($_POST['price'] ?? '');
$doors_open = sanitize_text($_POST['doors_open'] ?? '');
$tickets_info = sanitize_text($_POST['tickets_info'] ?? '');
$reserve_by_mail = isset($_POST['reserve_by_mail']) && $_POST['reserve_by_mail'] === '1';
$reserve_email = trim((string)($_POST['reserve_email'] ?? ''));
if ($reserve_by_mail && $reserve_email === '') {
$reserve_email = 'walschleber.kultour@gmail.com';
}
if ($reserve_email !== '' && !filter_var($reserve_email, FILTER_VALIDATE_EMAIL)) {
$reserve_email = '';
}
$catering = sanitize_text($_POST['catering'] ?? '');
$tags = sanitize_text($_POST['tags'] ?? '');
$description = trim($_POST['description'] ?? '');
$existing_image = sanitize_text($_POST['existing_image'] ?? '');
if ($title === '' || $date === '') {
$error = 'Titel und Datum sind Pflichtfelder.';
} else {
try {
$imagePath = $existing_image;
if (!empty($_FILES['image']['name'])) {
$imagePath = handle_image_upload($_FILES['image']);
}
$slug = make_slug($title, $date);
// Update falls vorhanden
$events = load_events();
$idx = null;
foreach ($events as $k => $ev) {
if ($ev['id'] === $id) { $idx = $k; break; }
}
$record = [
'id' => $id,
'title' => $title,
'speaker' => $speaker,
'date' => $date,
'time' => $time,
'venue_name' => $venue_name,
'venue_address' => $venue_address,
'price' => $price,
'doors_open' => $doors_open,
'tickets_info' => $tickets_info,
'reserve_by_mail' => $reserve_by_mail,
'reserve_email' => $reserve_email,
'catering' => $catering,
'tags' => $tags,
'description' => $description,
'image' => $imagePath,
'slug' => $slug,
'updated_at' => now_iso()
];
if ($idx !== null) {
$events[$idx] = $record;
} else {
$record['created_at'] = now_iso();
$events[] = $record;
}
save_events($events);
header('Location: admin.php?ok=1');
exit;
} catch (Throwable $e) {
$error = 'Fehler: ' . $e->getMessage();
}
}
}
// DELETE
if (isset($_GET['delete'])) {
$events = load_events();
$id = $_GET['delete'];
$events = array_values(array_filter($events, fn($e) => $e['id'] !== $id));
save_events($events);
header('Location: admin.php?deleted=1');
exit;
}
// EDIT load
$editEvent = null;
if (isset($_GET['edit'])) {
$id = $_GET['edit'];
foreach (load_events() as $e) {
if ($e['id'] === $id) { $editEvent = $e; break; }
}
}
try {
$nonWebpUploads = find_non_webp_uploads();
} catch (Throwable $e) {
$nonWebpUploads = [];
$migrationError = 'Upload-Prüfung fehlgeschlagen: ' . $e->getMessage();
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin | <?= h(SITE_NAME) ?></title>
<meta name="robots" content="noindex,nofollow">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<link rel="icon" href="/assets/logo.png" sizes="32x32" type="image/png">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom sticky-top">
<div class="container">
<a class="navbar-brand brand-logo" href="index.html">
<img src="/assets/logo.png" alt="Walschleber KulTour Logo" height="34" width="34">
<span><?= h(SITE_NAME) ?></span>
</a>
<div class="ms-auto">
<?php if ($loggedIn): ?>
<a class="btn btn-outline-secondary btn-sm" href="admin.php?logout=1">Logout</a>
<?php endif; ?>
</div>
</div>
</nav>
<?php if (!$loggedIn): ?>
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="akzent-line mb-3"></div>
<h1 class="display-6 mb-2">Admin-Login</h1>
</div>
</header>
<main class="container my-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="p-4 border rounded-3 bg-white">
<?php if (!empty($error)): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<form method="post" autocomplete="off">
<input type="hidden" name="action" value="login">
<div class="mb-3">
<label class="form-label">Passwort</label>
<input type="password" class="form-control form-control-lg" name="password" required>
</div>
<button class="btn btn-brand btn-lg w-100">Einloggen</button>
</form>
</div>
</div>
</div>
</main>
<?php else: ?>
<header class="py-5 bg-light border-bottom">
<div class="container">
<div class="akzent-line mb-3"></div>
<h1 class="display-6 mb-2">Vorträge verwalten</h1>
<p class="text-muted mb-0">Anlegen, bearbeiten, Bilder hochladen.</p>
</div>
</header>
<main class="container my-4">
<div class="d-flex align-items-center gap-2 mb-3">
<?php if (isset($_GET['ok'])): ?><span class="badge text-bg-success">Gespeichert</span><?php endif; ?>
<?php if (isset($_GET['deleted'])): ?><span class="badge text-bg-warning">Gelöscht</span><?php endif; ?>
</div>
<?php if (!empty($migrationError)): ?>
<div class="alert alert-danger" role="alert"><?= h($migrationError) ?></div>
<?php endif; ?>
<?php if (!empty($nonWebpUploads)): ?>
<div
class="alert alert-warning"
id="webp-migration-panel"
data-total="<?= count($nonWebpUploads) ?>"
data-csrf="<?= h($migrationCsrf) ?>"
role="status"
>
<div class="d-md-flex justify-content-between align-items-center gap-3">
<div class="mb-3 mb-md-0">
<div class="fw-semibold">WebP-Migration verfügbar</div>
<div class="small" id="webp-migration-summary">
<?= count($nonWebpUploads) ?> vorhandene(s) Upload-Bild(er) sind noch nicht als WebP gespeichert.
Die Veranstaltungseinträge werden automatisch aktualisiert.
</div>
</div>
<button class="btn btn-warning text-nowrap" type="button" id="webp-migration-start">
In WebP konvertieren
</button>
</div>
<div class="mt-3 d-none" id="webp-migration-progress-wrap">
<div
class="progress"
role="progressbar"
aria-label="Fortschritt der WebP-Migration"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
>
<div
class="progress-bar progress-bar-striped progress-bar-animated"
id="webp-migration-progress"
style="width: 0%"
>0%</div>
</div>
<div class="small mt-2" id="webp-migration-status" aria-live="polite">Migration wird vorbereitet …</div>
</div>
</div>
<?php endif; ?>
<div class="row g-4">
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3"><?= $editEvent ? 'Vortrag bearbeiten' : 'Neuen Vortrag anlegen' ?></h2>
<?php if (!empty($error)): ?><div class="alert alert-danger"><?= h($error) ?></div><?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="id" value="<?= h($editEvent['id'] ?? '') ?>">
<div class="mb-3">
<label class="form-label">Titel*</label>
<input type="text" class="form-control" name="title" required value="<?= h($editEvent['title'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label">Referent/Autor</label>
<input type="text" class="form-control" name="speaker" value="<?= h($editEvent['speaker'] ?? '') ?>">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Datum*</label>
<input type="date" class="form-control" name="date" required value="<?= h($editEvent['date'] ?? '') ?>">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Uhrzeit</label>
<input type="time" class="form-control" name="time" value="<?= h($editEvent['time'] ?? '19:30') ?>">
</div>
</div>
<div class="mb-3">
<label class="form-label">Veranstaltungsort (Name)</label>
<input type="text" class="form-control" name="venue_name" value="<?= h($editEvent['venue_name'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label">Adresse (Straße, PLZ Ort)</label>
<input type="text" class="form-control" name="venue_address" value="<?= h($editEvent['venue_address'] ?? '') ?>">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Eintritt (z.B. 12 €)</label>
<input type="text" class="form-control" name="price" value="<?= h($editEvent['price'] ?? '') ?>">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Einlass (z.B. 18:30)</label>
<input type="text" class="form-control" name="doors_open" value="<?= h($editEvent['doors_open'] ?? '') ?>">
</div>
</div>
<div class="mb-3">
<label class="form-label">Ticket/VVK-Info</label>
<input type="text" class="form-control" name="tickets_info" value="<?= h($editEvent['tickets_info'] ?? '') ?>">
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="reserve_by_mail" name="reserve_by_mail" value="1" <?= !empty($editEvent['reserve_by_mail']) ? 'checked' : '' ?>>
<label class="form-check-label" for="reserve_by_mail">Tickets per EMail reservieren</label>
</div>
<div class="mb-3" id="reserve_email_wrap">
<label class="form-label">ReservierungsEMail (optional)</label>
<input type="email" class="form-control" name="reserve_email" value="<?= h($editEvent['reserve_email'] ?? '') ?>" placeholder="z.B. tickets@…">
<div class="form-text">Wenn leer, wird versucht, eine EMail-Adresse aus „Ticket/VVKInfo“ zu erkennen. Sonst bleibt der Empfänger leer.</div>
</div>
<div class="mb-3">
<label class="form-label">Verpflegung/Service</label>
<input type="text" class="form-control" name="catering" value="<?= h($editEvent['catering'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label">Tags (Kommagetrennt)</label>
<input type="text" class="form-control" name="tags" value="<?= h($editEvent['tags'] ?? '') ?>">
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea class="form-control" name="description" rows="6"><?= h($editEvent['description'] ?? '') ?></textarea>
</div>
<?php if (!empty($editEvent['image'])): ?>
<div class="mb-2">
<img src="<?= h($editEvent['image']) ?>" alt="aktuelles Bild" class="img-fluid rounded">
</div>
<input type="hidden" name="existing_image" value="<?= h($editEvent['image']) ?>">
<?php endif; ?>
<div class="mb-3">
<label class="form-label">Bild (JPG/PNG/WEBP, wird automatisch als WebP gespeichert)</label>
<input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.webp">
</div>
<button class="btn btn-brand" name="save_event" value="1">Speichern</button>
<?php if ($editEvent): ?>
<a href="admin.php" class="btn btn-outline-secondary ms-2">Abbrechen</a>
<?php endif; ?>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h5 mb-3">Kommende Vorträge</h2>
<?php
$events = load_events();
usort($events, 'by_datetime_desc');
$upcoming = array_values(array_filter($events, 'is_upcoming'));
$archived = array_values(array_filter($events, fn($e) => !is_upcoming($e)));
?>
<?php if (!$upcoming): ?>
<p class="text-muted">Noch keine Einträge.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Ort</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
<?php foreach ($upcoming as $e): ?>
<tr>
<td><?= h($e['date']) ?> <?= h($e['time']) ?></td>
<td>
<div class="fw-semibold"><?= h($e['title']) ?></div>
<div class="text-muted small"><?= h($e['speaker']) ?></div>
<?php if (!empty($e['reserve_by_mail'])): ?>
<span class="badge text-bg-info">MailReservierung</span>
<?php endif; ?>
</td>
<td><?= h($e['venue_name']) ?></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="admin.php?edit=<?= h($e['id']) ?>">Bearbeiten</a>
<a class="btn btn-sm btn-outline-danger" href="admin.php?delete=<?= h($e['id']) ?>" onclick="return confirm('Wirklich löschen?')">Löschen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<div class="mt-4">
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#archive-events" aria-expanded="false" aria-controls="archive-events">
Archivierte Vorträge (<?= count($archived) ?>)
</button>
<div class="collapse mt-3" id="archive-events">
<?php if (!$archived): ?>
<p class="text-muted mb-0">Keine archivierten Vorträge vorhanden.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Datum</th>
<th>Titel</th>
<th>Ort</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
<?php foreach ($archived as $e): ?>
<tr>
<td><?= h($e['date']) ?> <?= h($e['time']) ?></td>
<td>
<div class="fw-semibold"><?= h($e['title']) ?></div>
<div class="text-muted small"><?= h($e['speaker']) ?></div>
<?php if (!empty($e['reserve_by_mail'])): ?>
<span class="badge text-bg-info">MailReservierung</span>
<?php endif; ?>
</td>
<td><?= h($e['venue_name']) ?></td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="admin.php?edit=<?= h($e['id']) ?>">Bearbeiten</a>
<a class="btn btn-sm btn-outline-danger" href="admin.php?delete=<?= h($e['id']) ?>" onclick="return confirm('Wirklich löschen?')">Löschen</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<section class="my-4">
<div class="cta-band p-4 d-md-flex justify-content-between align-items-center rounded-4">
<div class="mb-3 mb-md-0">
<p class="section-kicker mb-1">Tipp</p>
<div class="h6 mb-0">Wenn „Ticket/VVK-Info“ eine URL enthält, zeigt die Programmseite automatisch den Button <em>„Tickets sichern“</em>. Wenn „Tickets per EMail reservieren“ aktiv ist, erscheint zusätzlich ein Button, der eine Mailvorlage im Mailprogramm öffnet.</div>
</div>
<a class="btn btn-accent" href="programm.php" target="_blank" rel="noopener">Programm ansehen</a>
</div>
</section>
</div>
</div>
</main>
<?php endif; ?>
<footer class="py-4 border-top mt-5">
<div class="container small d-flex gap-3 flex-wrap">
<span>&copy; <?= date('Y') ?> <?= h(SITE_NAME) ?></span>
<a href="impressum.html">Impressum</a>
<a href="datenschutz.html">Datenschutz</a>
<a class="footer-gitea" href="https://git.mrblake.cc/MrBlake/Walschleber-Kultour-CMS" target="_blank" rel="noopener noreferrer" aria-label="Quellcode auf Gitea ansehen" title="Quellcode auf Gitea">
<img src="/assets/gitea.svg" width="18" height="18" alt="">
</a>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Admin UI: ReservierungsEMail Feld nur anzeigen, wenn „Tickets per EMail reservieren“ aktiv ist
(function () {
const toggle = document.getElementById('reserve_by_mail');
const wrap = document.getElementById('reserve_email_wrap');
if (!toggle || !wrap) return;
const apply = () => { wrap.style.display = toggle.checked ? '' : 'none'; };
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);
})();
</script>
</body>
</html>