[]], 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(); $json = json_encode( ['events' => array_values($events)], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ); $fp = fopen(DATA_FILE, 'c+'); if ($fp === false) { throw new RuntimeException('Kann Datei nicht öffnen: ' . DATA_FILE); } if (!flock($fp, LOCK_EX)) { fclose($fp); throw new RuntimeException('Kann Datenbank nicht sperren: ' . DATA_FILE); } try { if (!ftruncate($fp, 0) || !rewind($fp)) { throw new RuntimeException('Kann Datenbank nicht leeren: ' . DATA_FILE); } $length = strlen($json); $offset = 0; while ($offset < $length) { $written = fwrite($fp, substr($json, $offset)); if ($written === false || $written === 0) { throw new RuntimeException('Kann Datenbank nicht schreiben: ' . DATA_FILE); } $offset += $written; } if (!fflush($fp)) { throw new RuntimeException('Kann Datenbank nicht speichern: ' . DATA_FILE); } } finally { 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 } /** * Findet vorhandene Bilddateien im Upload-Ordner, deren Name nicht auf .webp endet. */ function find_non_webp_uploads(): array { ensure_storage(); $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo === false) { throw new RuntimeException('Die vorhandenen Uploads konnten nicht geprüft werden.'); } $uploads = []; try { foreach (new DirectoryIterator(UPLOAD_DIR) as $file) { if (!$file->isFile() || $file->isDot()) { continue; } $name = $file->getFilename(); if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'webp') { continue; } $mime = finfo_file($finfo, $file->getPathname()); if (!is_string($mime) || !in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) { continue; } if (@getimagesize($file->getPathname()) === false) { continue; } $uploads[] = [ 'name' => $name, 'path' => $file->getPathname(), 'mime' => $mime, 'size' => $file->getSize(), ]; } } finally { finfo_close($finfo); } usort($uploads, static function (array $left, array $right): int { return strcasecmp($left['name'], $right['name']); }); return $uploads; } function load_events_for_migration(): array { ensure_storage(); $raw = file_get_contents(DATA_FILE); if ($raw === false) { throw new RuntimeException('Die Datenbank konnte nicht gelesen werden.'); } try { $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { throw new RuntimeException('Die Datenbank enthält ungültiges JSON. Migration wurde abgebrochen.', 0, $exception); } if (!is_array($data) || !isset($data['events']) || !is_array($data['events'])) { throw new RuntimeException('Die Datenbank hat ein ungültiges Format. Migration wurde abgebrochen.'); } return array_values($data['events']); } /** * Konvertiert Bestandsbilder nach WebP und aktualisiert anschließend ihre JSON-Verweise. * Originaldateien werden erst nach einer erfolgreichen Datenbankaktualisierung entfernt. */ function migrate_uploads_to_webp(): array { $uploads = find_non_webp_uploads(); if (!$uploads) { return ['processed' => 0, 'references' => 0, 'removed' => 0, 'remaining' => []]; } $uploadDir = rtrim(UPLOAD_DIR, '/\\'); $createdFiles = []; $temporaryFiles = []; $mapping = []; $events = load_events_for_migration(); $databaseBackup = null; try { foreach ($uploads as $upload) { $contentHash = hash_file('sha256', $upload['path']); if ($contentHash === false) { throw new RuntimeException('Prüfsumme fehlgeschlagen: ' . $upload['name']); } $stem = pathinfo($upload['name'], PATHINFO_FILENAME); $stem = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $stem); $stem = trim((string)$stem, '-_'); if ($stem === '') { $stem = 'legacy-image'; } $stem = substr($stem, 0, 160); $targetName = $stem . '-converted-' . substr($contentHash, 0, 10) . '.webp'; $targetPath = $uploadDir . '/' . $targetName; if (is_file($targetPath)) { $targetMime = mime_content_type($targetPath); if ($targetMime !== 'image/webp' || @getimagesize($targetPath) === false) { throw new RuntimeException('Vorhandene Zieldatei ist kein gültiges WebP: ' . $targetName); } } else { $temporaryPath = $uploadDir . '/.' . $targetName . '.' . bin2hex(random_bytes(4)) . '.tmp'; $temporaryFiles[] = $temporaryPath; convert_image_to_webp($upload['path'], $upload['mime'], $temporaryPath, WEBP_QUALITY); if (mime_content_type($temporaryPath) !== 'image/webp' || @getimagesize($temporaryPath) === false) { throw new RuntimeException('Konvertierung konnte nicht validiert werden: ' . $upload['name']); } if (!@rename($temporaryPath, $targetPath)) { throw new RuntimeException('WebP-Datei konnte nicht gespeichert werden: ' . $targetName); } $temporaryFiles = array_values(array_filter( $temporaryFiles, static function (string $path) use ($temporaryPath): bool { return $path !== $temporaryPath; } )); $createdFiles[] = $targetPath; } $mapping[$upload['name']] = '/uploads/' . $targetName; } $updatedReferences = 0; foreach ($events as &$event) { $image = (string)($event['image'] ?? ''); if ($image === '') { continue; } $path = parse_url($image, PHP_URL_PATH); $sourceName = rawurldecode(basename(is_string($path) ? $path : $image)); if (isset($mapping[$sourceName])) { $event['image'] = $mapping[$sourceName]; $updatedReferences++; } } unset($event); if ($updatedReferences > 0) { $databaseBackup = DATA_FILE . '.migration-' . bin2hex(random_bytes(6)) . '.bak'; if (!@copy(DATA_FILE, $databaseBackup)) { throw new RuntimeException('Die Datenbank konnte vor der Migration nicht gesichert werden.'); } try { save_events($events); } catch (Throwable $exception) { if (!@copy($databaseBackup, DATA_FILE)) { $backupToKeep = $databaseBackup; $databaseBackup = null; throw new RuntimeException( 'Datenbankaktualisierung und Wiederherstellung sind fehlgeschlagen. Backup: ' . $backupToKeep, 0, $exception ); } throw $exception; } } } catch (Throwable $exception) { foreach (array_merge($temporaryFiles, $createdFiles) as $path) { if (is_file($path)) { @unlink($path); } } throw $exception; } finally { if ($databaseBackup !== null && is_file($databaseBackup)) { @unlink($databaseBackup); } } $removed = 0; $remaining = []; foreach ($uploads as $upload) { if (@unlink($upload['path'])) { $removed++; } else { $remaining[] = $upload['name']; } } return [ 'processed' => count($uploads), 'references' => $updatedReferences, 'removed' => $removed, 'remaining' => $remaining, ]; } /** * 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 = '
'; $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( '/]*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 ''; }, $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; }