Files
Walschleber-Kultour-CMS/admin.php
T
MrBlake 20afd17ce7 Ergänze sichere WebP-Bestandsmigration
Zeigt bei älteren Upload-Bildern automatisch einen geschützten Migrationsbutton im Adminbereich an und aktualisiert die JSON-Verweise transaktionssicher. Ergänzt außerdem einen Server-Updater, der Datenbank, Uploads und Konfiguration beim Wechsel von älteren Versionen sichert und wiederherstellt.
2026-08-22 10:22:29 +02:00

497 lines
21 KiB
PHP
Raw 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;
// 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'];
$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
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'] ?? '');
$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 (is_array($migrationNotice)): ?>
<?php if (!empty($migrationNotice['remaining'])): ?>
<div class="alert alert-warning" role="alert">
<?= (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.
</div>
<?php else: ?>
<div class="alert alert-success" role="alert">
Migration abgeschlossen: <?= (int)$migrationNotice['processed'] ?> Bild(er) konvertiert und
<?= (int)$migrationNotice['references'] ?> Datenbank-Verweis(e) aktualisiert.
</div>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($nonWebpUploads)): ?>
<div class="alert alert-warning d-md-flex justify-content-between align-items-center gap-3" role="alert">
<div class="mb-3 mb-md-0">
<div class="fw-semibold">WebP-Migration verfügbar</div>
<div class="small">
<?= count($nonWebpUploads) ?> vorhandene(s) Upload-Bild(er) sind noch nicht als WebP gespeichert.
Die Veranstaltungseinträge werden automatisch aktualisiert.
</div>
</div>
<form method="post" onsubmit="return confirm('Alle erkannten Bestandsbilder jetzt nach WebP konvertieren?');">
<input type="hidden" name="migration_csrf" value="<?= h($migrationCsrf) ?>">
<button class="btn btn-warning text-nowrap" type="submit" name="migrate_uploads" value="1">
In WebP konvertieren
</button>
</form>
</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();
})();
</script>
</body>
</html>