Runtime-Daten aus Git entfernen, WebP-Konvertierung ergänzen, Installation und Updates dokumentieren sowie den Repository-Link im Footer hinzufügen.
447 lines
15 KiB
PHP
447 lines
15 KiB
PHP
<?php
|
|
// functions.php
|
|
declare(strict_types=1);
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
function ensure_storage(): void {
|
|
if (!is_dir(dirname(DATA_FILE))) {
|
|
@mkdir(dirname(DATA_FILE), 0775, true);
|
|
}
|
|
if (!is_file(DATA_FILE)) {
|
|
file_put_contents(DATA_FILE, json_encode(['events' => []], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
if (!is_dir(UPLOAD_DIR)) {
|
|
@mkdir(UPLOAD_DIR, 0775, true);
|
|
}
|
|
}
|
|
|
|
function load_events(): array {
|
|
ensure_storage();
|
|
$raw = file_get_contents(DATA_FILE);
|
|
$data = json_decode($raw ?: '{"events":[]}', true);
|
|
return is_array($data) && isset($data['events']) ? $data['events'] : [];
|
|
}
|
|
|
|
function save_events(array $events): void {
|
|
ensure_storage();
|
|
$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);
|
|
}
|
|
|
|
function by_datetime_asc(array $a, array $b): int {
|
|
return strtotime($a['date'].' '.$a['time']) <=> strtotime($b['date'].' '.$b['time']);
|
|
}
|
|
function by_datetime_desc(array $a, array $b): int {
|
|
return strtotime($b['date'].' '.$b['time']) <=> strtotime($a['date'].' '.$a['time']);
|
|
}
|
|
|
|
function now_iso(): string {
|
|
return date('Y-m-d\TH:i:sP');
|
|
}
|
|
|
|
function make_slug(string $title, string $date): string {
|
|
$t = mb_strtolower($title, 'UTF-8');
|
|
$t = preg_replace('~[^\pL\d]+~u', '-', $t);
|
|
$t = trim($t, '-');
|
|
$t = preg_replace('~[^-\w]+~', '', $t);
|
|
$t = preg_replace('~-+~', '-', $t);
|
|
return $t . '-' . $date;
|
|
}
|
|
|
|
function event_path(array $event): string {
|
|
if (!empty($event['slug'])) {
|
|
return '/event.php?slug=' . rawurlencode($event['slug']);
|
|
}
|
|
if (!empty($event['id'])) {
|
|
return '/event.php?id=' . rawurlencode($event['id']);
|
|
}
|
|
return '/event.php';
|
|
}
|
|
|
|
function event_url(array $event): string {
|
|
return rtrim(BASE_URL, '/') . event_path($event);
|
|
}
|
|
|
|
function sanitize_text(string $s): string {
|
|
return trim($s);
|
|
}
|
|
|
|
function is_upcoming(array $event): bool {
|
|
$ts = strtotime($event['date'].' '.$event['time']);
|
|
return $ts >= time();
|
|
}
|
|
|
|
function handle_image_upload(?array $file): ?string {
|
|
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
|
|
return null;
|
|
}
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
throw new RuntimeException('Upload-Fehler (Code '.$file['error'].')');
|
|
}
|
|
|
|
$sourcePath = (string)($file['tmp_name'] ?? '');
|
|
if ($sourcePath === '' || !is_file($sourcePath)) {
|
|
throw new RuntimeException('Die hochgeladene Bilddatei fehlt.');
|
|
}
|
|
|
|
$allowed = ['image/jpeg', 'image/png', 'image/webp'];
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
if ($finfo === false) {
|
|
throw new RuntimeException('Der Dateityp konnte nicht geprüft werden.');
|
|
}
|
|
$mime = finfo_file($finfo, $sourcePath);
|
|
finfo_close($finfo);
|
|
if (!is_string($mime) || !in_array($mime, $allowed, true)) {
|
|
throw new RuntimeException('Nur JPG/PNG/WEBP erlaubt.');
|
|
}
|
|
|
|
if (@getimagesize($sourcePath) === false) {
|
|
throw new RuntimeException('Die hochgeladene Datei ist kein gültiges Bild.');
|
|
}
|
|
|
|
ensure_storage();
|
|
$uploadToken = 'event_'.date('Ymd_His').'_' . bin2hex(random_bytes(4));
|
|
$basename = $uploadToken . '.webp';
|
|
$uploadDir = rtrim(UPLOAD_DIR, '/\\');
|
|
$stagedSource = $uploadDir . '/.' . $uploadToken . '.upload';
|
|
$stagedWebp = $uploadDir . '/.' . $uploadToken . '.webp.tmp';
|
|
$destination = $uploadDir . '/' . $basename;
|
|
|
|
if (!move_uploaded_file($sourcePath, $stagedSource)) {
|
|
throw new RuntimeException('Konnte Datei nicht speichern.');
|
|
}
|
|
|
|
try {
|
|
convert_image_to_webp($stagedSource, $mime, $stagedWebp, WEBP_QUALITY);
|
|
$webpSize = is_file($stagedWebp) ? filesize($stagedWebp) : false;
|
|
if ($webpSize === false || $webpSize === 0) {
|
|
throw new RuntimeException('Die WebP-Datei konnte nicht erstellt werden.');
|
|
}
|
|
if (!@rename($stagedWebp, $destination)) {
|
|
throw new RuntimeException('Die WebP-Datei konnte nicht gespeichert werden.');
|
|
}
|
|
} finally {
|
|
if (is_file($stagedSource)) {
|
|
@unlink($stagedSource);
|
|
}
|
|
if (is_file($stagedWebp)) {
|
|
@unlink($stagedWebp);
|
|
}
|
|
}
|
|
|
|
return '/uploads/'.$basename; // Pfad relativ zur Webroot
|
|
}
|
|
|
|
/**
|
|
* Konvertiert ein geprüftes JPG-, PNG- oder WebP-Bild in eine WebP-Datei.
|
|
* Unterstützt GD mit WebP-Support sowie Imagick als Alternative.
|
|
*/
|
|
function convert_image_to_webp(string $source, string $mime, string $destination, int $quality = 85): void {
|
|
$quality = max(0, min(100, $quality));
|
|
$gdLoaders = [
|
|
'image/jpeg' => 'imagecreatefromjpeg',
|
|
'image/png' => 'imagecreatefrompng',
|
|
'image/webp' => 'imagecreatefromwebp',
|
|
];
|
|
$gdLoader = $gdLoaders[$mime] ?? null;
|
|
|
|
if ($gdLoader !== null && function_exists($gdLoader) && function_exists('imagewebp')) {
|
|
convert_image_to_webp_with_gd($source, $mime, $destination, $quality, $gdLoader);
|
|
return;
|
|
}
|
|
|
|
if (class_exists('Imagick')) {
|
|
convert_image_to_webp_with_imagick($source, $destination, $quality);
|
|
return;
|
|
}
|
|
|
|
throw new RuntimeException(
|
|
'WebP-Konvertierung ist auf dem Server nicht verfügbar. Bitte PHP-GD mit WebP-Support oder Imagick aktivieren.'
|
|
);
|
|
}
|
|
|
|
function convert_image_to_webp_with_gd(
|
|
string $source,
|
|
string $mime,
|
|
string $destination,
|
|
int $quality,
|
|
string $loader
|
|
): void {
|
|
$image = @$loader($source);
|
|
if ($image === false) {
|
|
throw new RuntimeException('Das Bild konnte nicht gelesen werden.');
|
|
}
|
|
|
|
try {
|
|
if ($mime === 'image/jpeg') {
|
|
$image = apply_jpeg_exif_orientation($image, $source);
|
|
}
|
|
|
|
if (function_exists('imagepalettetotruecolor')
|
|
&& function_exists('imageistruecolor')
|
|
&& !imageistruecolor($image)
|
|
) {
|
|
imagepalettetotruecolor($image);
|
|
}
|
|
imagealphablending($image, true);
|
|
imagesavealpha($image, true);
|
|
|
|
if (!@imagewebp($image, $destination, $quality)) {
|
|
throw new RuntimeException('Das Bild konnte nicht als WebP gespeichert werden.');
|
|
}
|
|
} finally {
|
|
imagedestroy($image);
|
|
}
|
|
}
|
|
|
|
function apply_jpeg_exif_orientation($image, string $source) {
|
|
if (!function_exists('exif_read_data')) {
|
|
return $image;
|
|
}
|
|
|
|
$exif = @exif_read_data($source, 'IFD0', true);
|
|
$orientation = (int)($exif['IFD0']['Orientation'] ?? $exif['Orientation'] ?? 1);
|
|
|
|
if (in_array($orientation, [2, 4, 5, 7], true)) {
|
|
$flipMode = in_array($orientation, [2, 5, 7], true) ? IMG_FLIP_HORIZONTAL : IMG_FLIP_VERTICAL;
|
|
if (!imageflip($image, $flipMode)) {
|
|
throw new RuntimeException('Die Bildausrichtung konnte nicht korrigiert werden.');
|
|
}
|
|
}
|
|
|
|
$angle = 0;
|
|
if ($orientation === 3) {
|
|
$angle = 180;
|
|
} elseif (in_array($orientation, [5, 6], true)) {
|
|
$angle = -90;
|
|
} elseif (in_array($orientation, [7, 8], true)) {
|
|
$angle = 90;
|
|
}
|
|
if ($angle === 0) {
|
|
return $image;
|
|
}
|
|
|
|
$transparent = imagecolorallocatealpha($image, 0, 0, 0, 127);
|
|
$rotated = imagerotate($image, $angle, $transparent);
|
|
if ($rotated === false) {
|
|
throw new RuntimeException('Die Bildausrichtung konnte nicht korrigiert werden.');
|
|
}
|
|
imagesavealpha($rotated, true);
|
|
imagedestroy($image);
|
|
|
|
return $rotated;
|
|
}
|
|
|
|
function convert_image_to_webp_with_imagick(
|
|
string $source,
|
|
string $destination,
|
|
int $quality
|
|
): void {
|
|
$sourceImage = new Imagick();
|
|
$image = null;
|
|
|
|
try {
|
|
$sourceImage->readImage($source);
|
|
$sourceImage->setIteratorIndex(0);
|
|
$image = $sourceImage->getImage();
|
|
|
|
if (method_exists($image, 'autoOrientImage')) {
|
|
$image->autoOrientImage();
|
|
}
|
|
$image->setImageFormat('webp');
|
|
$image->setImageCompressionQuality($quality);
|
|
$image->stripImage();
|
|
|
|
if (!$image->writeImage($destination)) {
|
|
throw new RuntimeException('Das Bild konnte nicht als WebP gespeichert werden.');
|
|
}
|
|
} catch (Throwable $exception) {
|
|
if ($exception instanceof RuntimeException) {
|
|
throw $exception;
|
|
}
|
|
throw new RuntimeException('Das Bild konnte nicht in WebP konvertiert werden.', 0, $exception);
|
|
} finally {
|
|
if ($image instanceof Imagick) {
|
|
$image->clear();
|
|
$image->destroy();
|
|
}
|
|
$sourceImage->clear();
|
|
$sourceImage->destroy();
|
|
}
|
|
}
|
|
|
|
function render_event_jsonld_graph(array $events): string {
|
|
$graph = [];
|
|
foreach ($events as $e) {
|
|
$startTime = $e['time'] ?? '19:30';
|
|
$start = $e['date'] . 'T' . $startTime . ':00+02:00'; // Berlin Zeit im Sommer; vereinfacht
|
|
$price = $e['price'] ?? '';
|
|
$priceValue = normalize_price_value($price);
|
|
$validFrom = $e['created_at'] ?? $e['updated_at'] ?? null;
|
|
$endDate = build_event_end_date($e, $startTime);
|
|
$offers = $price !== '' ? array_filter([
|
|
"@type" => "Offer",
|
|
"url" => event_url($e),
|
|
"price" => $priceValue,
|
|
"priceCurrency" => "EUR",
|
|
"availability" => "https://schema.org/InStock",
|
|
"validFrom" => $validFrom
|
|
]) : null;
|
|
|
|
$graph[] = array_filter([
|
|
"@type" => "Event",
|
|
"name" => $e['title'],
|
|
"url" => event_url($e),
|
|
"startDate" => $start,
|
|
"endDate" => $endDate,
|
|
"eventStatus" => "https://schema.org/EventScheduled",
|
|
"eventAttendanceMode" => "https://schema.org/OfflineEventAttendanceMode",
|
|
"performer" => !empty($e['speaker']) ? [
|
|
"@type" => "Person",
|
|
"name" => $e['speaker']
|
|
] : null,
|
|
"location" => [
|
|
"@type" => "Place",
|
|
"name" => $e['venue_name'] ?? "Veranstaltungsort",
|
|
"address" => [
|
|
"@type" => "PostalAddress",
|
|
"streetAddress" => $e['venue_address'] ?? "",
|
|
"addressLocality" => "Walschleben",
|
|
"postalCode" => "99189",
|
|
"addressCountry" => "DE"
|
|
],
|
|
],
|
|
"image" => isset($e['image']) && $e['image'] !== '' ? [ BASE_URL . $e['image'] ] : [ BASE_URL . DEFAULT_OG_IMAGE ],
|
|
"description" => $e['description'] ?? "",
|
|
"organizer" => [
|
|
"@type" => "Organization",
|
|
"name" => SITE_NAME,
|
|
"url" => BASE_URL
|
|
],
|
|
"offers" => $offers
|
|
]);
|
|
}
|
|
return json_encode([
|
|
"@context" => "https://schema.org",
|
|
"@graph" => $graph
|
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
|
}
|
|
|
|
function h(?string $s): string {
|
|
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
}
|
|
|
|
function normalize_price_value(string $price): ?string {
|
|
$value = preg_replace('/[^\d,\.]/', '', $price);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
if (substr_count($value, ',') > 0 && substr_count($value, '.') > 0) {
|
|
$value = str_replace('.', '', $value);
|
|
$value = str_replace(',', '.', $value);
|
|
} else {
|
|
$value = str_replace(',', '.', $value);
|
|
}
|
|
return rtrim($value, '.');
|
|
}
|
|
|
|
function build_event_end_date(array $event, string $fallbackTime): ?string {
|
|
$endDate = trim((string)($event['end_date'] ?? ''));
|
|
$endTime = trim((string)($event['end_time'] ?? ''));
|
|
$time = $endTime !== '' ? $endTime : $fallbackTime;
|
|
if ($endDate !== '') {
|
|
return $endDate . 'T' . $time . ':00+02:00';
|
|
}
|
|
// Falls keine Endzeit gepflegt ist, nehmen wir das Startdatum, damit Schema-Validatoren zufrieden sind.
|
|
if (!empty($event['date'])) {
|
|
return $event['date'] . 'T' . $time . ':00+02:00';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Erkennt, ob HTML-Tags enthalten sind.
|
|
*/
|
|
function has_html_tags(string $s): bool {
|
|
return (bool) preg_match('/<\s*\w+[^>]*>/', $s);
|
|
}
|
|
|
|
/**
|
|
* Erlaubte, sichere HTML-Ausgabe (Whitelist + Attribut-Filter).
|
|
* Erlaubt: p, br, ul/ol/li, b/strong, i/em, u, a, blockquote, h3-h5
|
|
* Entfernt: on* Handler, javascript: URLs, style/iframe/script usw.
|
|
* Fügt bei Links rel="noopener nofollow ugc" hinzu und erlaubt nur http(s).
|
|
*/
|
|
function render_rich_text(string $input): string {
|
|
$input = trim($input);
|
|
|
|
// Fall A: Kein HTML -> wir schützen & konvertieren Zeilenumbrüche
|
|
if (!has_html_tags($input)) {
|
|
return nl2br(h($input));
|
|
}
|
|
|
|
// Fall B: HTML vorhanden -> nur erlaubte Tags behalten
|
|
$allowed = '<p><br><ul><ol><li><b><strong><i><em><u><a><blockquote><h3><h4><h5>';
|
|
$html = strip_tags($input, $allowed);
|
|
|
|
// Gefährliche Event-Handler entfernen (on*)
|
|
$html = preg_replace('/\s+on\w+\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/i', '', $html);
|
|
|
|
// style-Attribute entfernen (vermeidet versteckte JS/CSS-Tricks)
|
|
$html = preg_replace('/\s+style\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/i', '', $html);
|
|
|
|
// href bereinigen: nur http/https erlauben, sonst entfernen
|
|
// Außerdem rel/target ergänzen
|
|
$html = preg_replace_callback(
|
|
'/<a\s+([^>]*href\s*=\s*(["\'])(.*?)\2[^>]*)>/i',
|
|
function ($m) {
|
|
$attr = $m[1];
|
|
$url = $m[3];
|
|
|
|
// javascript:, data:, mailto: usw. blocken (nur http/https)
|
|
if (!preg_match('~^https?://~i', $url)) {
|
|
// href wegnehmen
|
|
$attr = preg_replace('/\s*href\s*=\s*(".*?"|\'.*?\'|[^\s>]+)/i', '', $attr);
|
|
$url = null;
|
|
}
|
|
|
|
// target & rel setzen
|
|
if ($url) {
|
|
// target="_blank"
|
|
if (!preg_match('/\btarget\s*=/i', $attr)) {
|
|
$attr .= ' target="_blank"';
|
|
}
|
|
// rel ergänzen
|
|
if (preg_match('/\brel\s*=\s*("|\')(.*?)\1/i', $attr, $r)) {
|
|
$rels = array_map('trim', explode(' ', $r[2] ?: ''));
|
|
foreach (['noopener','nofollow','ugc'] as $need) {
|
|
if (!in_array($need, $rels, true)) $rels[] = $need;
|
|
}
|
|
$attr = preg_replace('/\brel\s*=\s*("|\')(.*?)\1/i', ' rel="'.implode(' ', $rels).'"', $attr);
|
|
} else {
|
|
$attr .= ' rel="noopener nofollow ugc"';
|
|
}
|
|
}
|
|
|
|
return '<a ' . trim($attr) . '>';
|
|
},
|
|
$html
|
|
);
|
|
|
|
// Restliche "href='javascript:…'"-Muster sicherheitshalber neutralisieren
|
|
$html = preg_replace('/href\s*=\s*(["\'])\s*javascript:[^"\']*\1/i', '', $html);
|
|
|
|
// Doppelte/überflüssige Leerzeichen in Tags aufräumen
|
|
$html = preg_replace('/\s{2,}/', ' ', $html);
|
|
|
|
return $html;
|
|
}
|