[]], 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 = '