0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); session_start(); } if (!is_installed()) { require_once __DIR__ . '/install.php'; exit; } $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $uri = rtrim($uri, '/'); if ($uri === '') $uri = '/'; if ($uri === '/paragrafy.svg') { header('Content-Type: image/svg+xml'); if (file_exists(__DIR__ . '/paragrafy.svg')) { readfile(__DIR__ . '/paragrafy.svg'); } exit; } // Rollierendes Backup (7 Tage) -- per externem Cron täglich aufzurufen, z. B. via crontab oder Uptime-Kuma-Healthcheck if ($uri === '/api/cron/backup') { require_cron_secret(); header('Content-Type: application/json'); echo json_encode(run_scheduled_backup()); exit; } // Webhook-Warteschlange abarbeiten -- per externem Cron alle paar Minuten aufzurufen if ($uri === '/api/cron/webhooks') { require_cron_secret(); header('Content-Type: application/json'); echo json_encode(process_webhook_queue()); exit; } // Geplante Veröffentlichungen live schalten -- projektübergreifend, für explizite Cron-Aufrufe if ($uri === '/api/cron/publish') { require_cron_secret(); header('Content-Type: application/json'); echo json_encode(check_and_publish_scheduled(get_db())); exit; } // Alte Public-Cache-Dateien aufräumen -- optional, per externem Cron; der File-Cache // rotiert auch ohne dies opportunistisch bei Schreibzugriffen (siehe cache.php). if ($uri === '/api/cron/cache-cleanup') { require_cron_secret(); header('Content-Type: application/json'); echo json_encode(['deleted' => public_cache_cleanup_all()]); exit; } if (str_starts_with($uri, '/admin')) { require_once __DIR__ . '/admin.php'; exit; } $host = get_current_host(); $db = get_db(); $stmt = $db->prepare("SELECT * FROM projects WHERE domain = ? OR domain = 'localhost' ORDER BY (domain = ?) DESC LIMIT 1"); $stmt->execute([$host, $host]); $project = $stmt->fetch(); if ($uri === '/embed.js') { header('Content-Type: application/javascript; charset=utf-8'); render_embed_js(); exit; } if ($uri === '/consent.js') { header('Content-Type: application/javascript; charset=utf-8'); render_consent_js($project ?: null); exit; } if ($uri === '/api/consent-log') { handle_consent_log($project ?: null, $db); exit; } if (!$project) { http_response_code(404); echo "

" . htmlspecialchars(t('public.error.no_project_title')) . "

" . t('public.error.no_project_desc', ['domain' => htmlspecialchars($host)]) . "

"; exit; } // Zero-Config-Fallback: bei jedem Seitenaufruf prüfen, ob für dieses Projekt etwas fällig ist. // Ersetzt keinen Cron (der greift auch ohne Traffic), fängt aber Instanzen ohne eingerichteten Cron auf. check_and_publish_scheduled($db, $project); $parts = array_values(array_filter(explode('/', $uri))); $primaryLang = $project['primary_lang'] ?: 'de'; $activeLangs = array_filter(array_map('trim', explode(',', $project['active_languages'] ?? 'de,en'))); // Cron Audit Endpoint (/api/cron/audit) if (!empty($parts) && $parts[0] === 'api' && ($parts[1] ?? '') === 'cron' && ($parts[2] ?? '') === 'audit') { require_cron_secret(); handle_cron_audit($project, $db); exit; } // Vorschau-Kennzeichnung (.../preview) abtrennen, gilt für Seiten- und API-Routen gleichermaßen $isPreview = false; if (!empty($parts) && end($parts) === 'preview') { array_pop($parts); $isPreview = true; } // JSON API Endpoint (/api/de/impressum oder /api/impressum) if (!empty($parts) && $parts[0] === 'api') { if (rate_limit_wait($db, 'api:' . get_client_ip(), 120, 5) > 0) { http_response_code(429); header('Content-Type: application/json'); echo json_encode(['error' => 'rate_limited']); exit; } record_login_failure($db, 'api:' . get_client_ip()); handle_json_api($parts, $project, $db, $primaryLang, $isPreview); exit; } if (empty($parts)) { $negotiated = negotiate_document_lang($activeLangs, $primaryLang); if ($negotiated) { header('Location: /' . $negotiated . '/', true, 302); exit; } render_public_overview($project, $db, $primaryLang, $primaryLang); exit; } if (count($parts) === 1) { $first = strtolower($parts[0]); if (in_array($first, $activeLangs) || (strlen($first) === 2 && !is_numeric($first))) { render_public_overview($project, $db, $first, $primaryLang); exit; } $lang = $primaryLang; $slug = $first; $negotiated = negotiate_document_lang($activeLangs, $primaryLang); if ($negotiated && find_public_translation($db, (int)$project['id'], $negotiated, $slug)) { $suffix = $isPreview ? '/preview' : ''; $qs = $_SERVER['QUERY_STRING'] ?? ''; header('Location: /' . $negotiated . '/' . $slug . $suffix . ($qs !== '' ? '?' . $qs : ''), true, 302); exit; } } else { $lang = strtolower($parts[0]); $slug = strtolower($parts[1]); } $trans = find_public_translation($db, (int)$project['id'], $lang, $slug); $fallbackFromLang = null; if (!$trans && !$isPreview) { $trans = find_public_translation_with_fallback($db, (int)$project['id'], $lang, $slug, $primaryLang); if ($trans) { $fallbackFromLang = $lang; } } if (!$trans) { http_response_code(404); render_public_404($project, $lang); exit; } $liveSlug = $trans['slug']; $etagHash = null; if ($isPreview) { if (empty($trans['scheduled_at'])) { http_response_code(404); render_public_404($project, $lang); exit; } header('Cache-Control: private, no-store'); $trans['title'] = $trans['scheduled_title'] !== '' ? $trans['scheduled_title'] : $trans['title']; $trans['slug'] = $trans['scheduled_slug'] !== '' ? $trans['scheduled_slug'] : $trans['slug']; $previewContent = $trans['scheduled_content'] !== '' ? $trans['scheduled_content'] : $trans['content']; } else { $previewContent = $trans['content']; $etag = build_public_cache_etag((int)$project['id'], $lang, $liveSlug, $trans['updated_at'], (int)($project['settings_version'] ?? 1), $trans['lang'], (int)$trans['id']); $etagHash = trim($etag, '"'); $lastMod = build_public_last_modified($trans['updated_at'], (string)($project['settings_updated_at'] ?? '')); if (check_conditional_request($etag, $lastMod)) { exit; } $cachedHtml = public_cache_get((int)$project['id'], 'html', $lang, $liveSlug, $etagHash); if ($cachedHtml !== null) { echo public_cache_fill_dynamic($cachedHtml); exit; } } $stmt = $db->prepare(" SELECT t.lang, t.slug, dt.slug as default_slug FROM translations t JOIN documents d ON t.document_id = d.id JOIN doc_types dt ON d.doc_type_id = dt.id WHERE t.document_id = ? AND t.status = 'published' "); $stmt->execute([$trans['document_id']]); $languages = $stmt->fetchAll(); $content = replace_placeholders(sanitize_legal_html($previewContent), $project); ob_start(); render_public_document($project, $trans, $content, $languages, $trans['lang'], $isPreview, $liveSlug, $fallbackFromLang); $html = ob_get_clean(); if (!$isPreview) { public_cache_put((int)$project['id'], 'html', $lang, $liveSlug, $etagHash, $html); } echo public_cache_fill_dynamic($html); function get_i18n_strings(string $lang): array { $dict = [ 'de' => [ 'toc' => 'Inhaltsverzeichnis', 'search_ph' => 'Dokument durchsuchen (z. B. Cookies, Haftung, Widerruf)...', 'read_time' => '~%d Min. Lesezeit', 'last_updated' => 'Stand: %s', 'print' => 'Drucken', 'overview_title' => 'Rechtliche Dokumente', 'overview_sub' => 'Rechtliche Hinweise und Bestimmungen.', 'not_found_title' => 'Dokument nicht gefunden', 'not_found_desc' => 'Der angeforderte Rechtstext existiert nicht oder wurde noch nicht freigegeben.', 'back_to_overview' => 'Zurück zur Übersicht', 'copy_anchor' => 'Direktlink kopieren', 'preview_suffix' => ' (Vorschau)', 'print_title' => 'Drucken oder als PDF speichern', 'preview_banner_text' => 'Vorschau: Dies ist die geplante Neufassung. Sie geht am %s Uhr live. Bis dahin gilt weiterhin die aktuelle Fassung.', 'preview_banner_link' => 'Aktuelle Fassung ansehen →', 'upcoming_banner_text' => 'Geplante Neufassung verfügbar: Diese Bestimmungen werden zum %s aktualisiert.', 'upcoming_banner_link' => 'Vorab ansehen →', 'powered_by_toc' => 'Bereitgestellt mit Paragrafy', 'powered_by_footer' => 'Bereitgestellt über Paragrafy', 'lang_fallback_notice' => 'Dieser Text liegt aktuell nicht auf %s vor — hier die verfügbare Version.' ], 'en' => [ 'toc' => 'Table of Contents', 'search_ph' => 'Search document (e.g. cookies, liability, revocation)...', 'read_time' => '~%d min read', 'last_updated' => 'Last updated: %s', 'print' => 'Print', 'overview_title' => 'Legal Documents', 'overview_sub' => 'Legal notices, policies and terms.', 'not_found_title' => 'Document not found', 'not_found_desc' => 'The requested legal document does not exist or has not been published yet.', 'back_to_overview' => 'Back to Overview', 'copy_anchor' => 'Copy section link', 'preview_suffix' => ' (Preview)', 'print_title' => 'Print or save as PDF', 'preview_banner_text' => 'Preview: this is the scheduled new version. It goes live at %s. Until then, the current version remains in effect.', 'preview_banner_link' => 'View current version →', 'upcoming_banner_text' => 'Upcoming revision available: these terms will be updated on %s.', 'upcoming_banner_link' => 'Preview now →', 'powered_by_toc' => 'Powered by Paragrafy', 'powered_by_footer' => 'Powered by Paragrafy', 'lang_fallback_notice' => 'This document isn\'t available in %s yet — showing the available version instead.' ], 'es' => [ 'toc' => 'Índice de contenidos', 'search_ph' => 'Buscar en el documento (ej. cookies, responsabilidad)...', 'read_time' => '~%d min de lectura', 'last_updated' => 'Última actualización: %s', 'print' => 'Imprimir', 'overview_title' => 'Documentos legales', 'overview_sub' => 'Avisos legales, términos y políticas.', 'not_found_title' => 'Documento no encontrado', 'not_found_desc' => 'El documento legal solicitado no existe o aún no ha sido publicado.', 'back_to_overview' => 'Volver a la vista general', 'copy_anchor' => 'Copiar enlace', 'preview_suffix' => ' (Vista previa)', 'print_title' => 'Imprimir o guardar como PDF', 'preview_banner_text' => 'Vista previa: esta es la nueva versión programada. Estará activa a partir del %s. Hasta entonces, sigue vigente la versión actual.', 'preview_banner_link' => 'Ver versión actual →', 'upcoming_banner_text' => 'Nueva versión programada disponible: estas condiciones se actualizarán el %s.', 'upcoming_banner_link' => 'Ver vista previa →', 'powered_by_toc' => 'Desarrollado con Paragrafy', 'powered_by_footer' => 'Desarrollado con Paragrafy', 'lang_fallback_notice' => 'Este documento todavía no está disponible en %s — se muestra la versión disponible.' ], 'fr' => [ 'toc' => 'Table des matières', 'search_ph' => 'Rechercher dans le document (ex. cookies, responsabilité)...', 'read_time' => '~%d min de lecture', 'last_updated' => 'Dernière mise à jour: %s', 'print' => 'Imprimer', 'overview_title' => 'Documents juridiques', 'overview_sub' => 'Mentions légales, conditions et politiques.', 'not_found_title' => 'Document non trouvé', 'not_found_desc' => 'Le document juridique demandé n\'existe pas ou n\'a pas encore été publié.', 'back_to_overview' => 'Retour à la vue d\'ensemble', 'copy_anchor' => 'Copier le lien', 'preview_suffix' => ' (Aperçu)', 'print_title' => 'Imprimer ou enregistrer en PDF', 'preview_banner_text' => 'Aperçu : voici la nouvelle version prévue. Elle entrera en vigueur le %s. D\'ici là, la version actuelle reste applicable.', 'preview_banner_link' => 'Voir la version actuelle →', 'upcoming_banner_text' => 'Nouvelle version prévue : ces dispositions seront mises à jour le %s.', 'upcoming_banner_link' => 'Aperçu anticipé →', 'powered_by_toc' => 'Propulsé par Paragrafy', 'powered_by_footer' => 'Propulsé par Paragrafy', 'lang_fallback_notice' => 'Ce document n\'est pas encore disponible en %s — voici la version disponible.' ], 'it' => [ 'toc' => 'Indice dei contenuti', 'search_ph' => 'Cerca nel documento (es. cookie, responsabilità)...', 'read_time' => '~%d min di lettura', 'last_updated' => 'Ultimo aggiornamento: %s', 'print' => 'Stampa', 'overview_title' => 'Documenti legali', 'overview_sub' => 'Note legali, termini e informative.', 'not_found_title' => 'Documento non trovato', 'not_found_desc' => 'Il documento legale richiesto non esiste o non è ancora stato pubblicato.', 'back_to_overview' => 'Torna alla panoramica', 'copy_anchor' => 'Copia link', 'preview_suffix' => ' (Anteprima)', 'print_title' => 'Stampa o salva come PDF', 'preview_banner_text' => 'Anteprima: questa è la nuova versione pianificata. Sarà attiva dal %s. Fino ad allora resta valida la versione attuale.', 'preview_banner_link' => 'Vedi versione attuale →', 'upcoming_banner_text' => 'Nuova versione pianificata disponibile: queste disposizioni saranno aggiornate il %s.', 'upcoming_banner_link' => 'Anteprima anticipata →', 'powered_by_toc' => 'Offerto da Paragrafy', 'powered_by_footer' => 'Offerto da Paragrafy', 'lang_fallback_notice' => 'Questo documento non è ancora disponibile in %s — ecco la versione disponibile.' ], 'nl' => [ 'toc' => 'Inhoudsopgave', 'search_ph' => 'Document doorzoeken (bijv. cookies, aansprakelijkheid)...', 'read_time' => '~%d min leestijd', 'last_updated' => 'Laatst bijgewerkt: %s', 'print' => 'Afdrukken', 'overview_title' => 'Juridische documenten', 'overview_sub' => 'Juridische kennisgevingen en voorwaarden.', 'not_found_title' => 'Document niet gevonden', 'not_found_desc' => 'Het opgevraagde juridische document bestaat niet of is nog niet gepubliceerd.', 'back_to_overview' => 'Terug naar het overzicht', 'copy_anchor' => 'Kopieer link', 'preview_suffix' => ' (Voorbeeld)', 'print_title' => 'Afdrukken of opslaan als PDF', 'preview_banner_text' => 'Voorbeeld: dit is de geplande nieuwe versie. Deze gaat live op %s. Tot die tijd blijft de huidige versie geldig.', 'preview_banner_link' => 'Huidige versie bekijken →', 'upcoming_banner_text' => 'Geplande wijziging beschikbaar: deze bepalingen worden bijgewerkt op %s.', 'upcoming_banner_link' => 'Vooraf bekijken →', 'powered_by_toc' => 'Mogelijk gemaakt door Paragrafy', 'powered_by_footer' => 'Mogelijk gemaakt door Paragrafy', 'lang_fallback_notice' => 'Dit document is nog niet beschikbaar in %s — hier de beschikbare versie.' ] ]; return $dict[$lang] ?? $dict['en']; } /** * DSGVO-Nachweispflicht: receives the fire-and-forget POST that /consent.js * sends whenever a visitor accepts/declines the cookie banner, and stores a * privacy-preserving proof record via log_consent(). Called from an * arbitrary third-party website, so it needs its own CORS handling (the * JSON Content-Type makes this a non-simple request, requiring an OPTIONS * preflight response) independent of the read-only JSON API above. */ function handle_consent_log(?array $project, PDO $db): void { header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(204); return; } header('Content-Type: application/json; charset=utf-8'); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$project || empty($project['consent_logging_enabled'])) { echo json_encode(['success' => false]); return; } $body = json_decode((string)file_get_contents('php://input'), true); if (!is_array($body)) { $body = []; } $action = (string)($body['action'] ?? ''); if ($action !== 'accepted' && $action !== 'declined') { http_response_code(400); echo json_encode(['success' => false]); return; } $consentId = substr(trim((string)($body['consentId'] ?? '')), 0, 100); $lang = substr(trim((string)($body['lang'] ?? '')), 0, 20); $textHash = substr(trim((string)($body['textHash'] ?? '')), 0, 64); if (!preg_match('/^[A-Za-z0-9\-]{1,100}$/', $consentId) || !preg_match('/^[a-f0-9]{64}$/', $textHash)) { http_response_code(400); echo json_encode(['success' => false]); return; } $rateIdentifier = 'consent:' . $project['id'] . ':' . get_client_ip(); if (rate_limit_wait($db, $rateIdentifier, 30, 15) > 0) { http_response_code(429); echo json_encode(['success' => false]); return; } record_login_failure($db, $rateIdentifier); log_consent( $db, (int)$project['id'], $consentId, $action, $lang, $textHash, get_client_ip(), (string)($_SERVER['HTTP_USER_AGENT'] ?? '') ); echo json_encode(['success' => true]); } function handle_cron_audit(array $project, PDO $db): void { header('Content-Type: application/json'); echo json_encode(run_audit_check($project, $db)); } function handle_json_api(array $parts, array $project, PDO $db, string $primaryLang, bool $isPreview = false): void { header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); $lang = $primaryLang; $slug = ''; if (count($parts) === 2) { $slug = strtolower($parts[1]); } elseif (count($parts) >= 3) { $lang = strtolower($parts[1]); $slug = strtolower($parts[2]); } if (empty($slug)) { http_response_code(400); echo json_encode(['error' => 'Missing document slug']); return; } $doc = find_public_translation($db, (int)$project['id'], $lang, $slug); $fallbackFromLang = null; if (!$doc && !$isPreview) { $doc = find_public_translation_with_fallback($db, (int)$project['id'], $lang, $slug, $primaryLang); if ($doc) { $fallbackFromLang = $lang; } } if (!$doc) { http_response_code(404); echo json_encode(['error' => 'Document not found or not published']); return; } $etagHash = null; if ($isPreview) { if (empty($doc['scheduled_at'])) { http_response_code(404); echo json_encode(['error' => 'No scheduled preview available for this document']); return; } header('Cache-Control: private, no-store'); $doc['title'] = $doc['scheduled_title'] !== '' ? $doc['scheduled_title'] : $doc['title']; $doc['slug'] = $doc['scheduled_slug'] !== '' ? $doc['scheduled_slug'] : $doc['slug']; $doc['content'] = $doc['scheduled_content'] !== '' ? $doc['scheduled_content'] : $doc['content']; } else { $etag = build_public_cache_etag((int)$project['id'], $lang, $doc['slug'], $doc['updated_at'], (int)($project['settings_version'] ?? 1), $doc['lang'], (int)$doc['id']); $etagHash = trim($etag, '"'); $lastMod = build_public_last_modified($doc['updated_at'], (string)($project['settings_updated_at'] ?? '')); if (check_conditional_request($etag, $lastMod)) { return; } $cachedJson = public_cache_get((int)$project['id'], 'json', $lang, $doc['slug'], $etagHash); if ($cachedJson !== null) { echo $cachedJson; return; } } $doc['rendered_html'] = replace_placeholders(sanitize_legal_html($doc['content']), $project); $response = [ 'project' => $project['name'], 'title' => $doc['title'], 'slug' => $doc['slug'], 'lang' => $doc['lang'], 'updated_at' => $doc['updated_at'], 'change_note' => $doc['change_note'], 'html' => $doc['rendered_html'], 'preview' => $isPreview ]; if ($isPreview) { $response['effective_date'] = date('c', strtotime($doc['scheduled_at'])); } if ($fallbackFromLang !== null) { $response['fallback'] = true; $response['requested_lang'] = $fallbackFromLang; } $json = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); if (!$isPreview && $json !== false) { public_cache_put((int)$project['id'], 'json', $lang, $doc['slug'], $etagHash, $json); } echo $json; } /** * render_public_document() enthaelt zwei Stellen, die trotz gleichem $trans['updated_at'] * pro Request unterschiedlich sein koennen (aktuelles Jahr im Footer, UI-Locale-Switcher * inkl. $_GET-Query). Damit der File-Cache trotzdem die vollstaendige Seite als String * speichern kann, rendert render_public_document() an diesen Stellen Sentinel-Tokens statt * der Live-Werte; diese Funktion ersetzt sie unmittelbar vor der Auslieferung (egal ob der * HTML-String frisch gerendert oder aus dem Datei-Cache gelesen wurde). */ function public_cache_fill_dynamic(string $html): string { return str_replace( ['__CACHE_YEAR__', '__CACHE_LOCALE_SWITCHER__'], [date('Y'), render_locale_switcher()], $html ); } function render_public_document(array $project, array $trans, string $content, array $languages, string $currentLang, bool $isPreview = false, ?string $liveSlug = null, ?string $fallbackFromLang = null): void { $liveSlug = $liveSlug ?? $trans['slug']; $brand = htmlspecialchars($project['brand_color'] ?: '#F0A63C'); $logoUrl = $project['logo_url'] ?: '/paragrafy.svg'; $i18n = get_i18n_strings($currentLang); $wordCount = str_word_count(strip_tags($content)); $readMinutes = max(1, (int)ceil($wordCount / 200)); $readTimeStr = sprintf($i18n['read_time'], $readMinutes); $lastUpdatedStr = sprintf($i18n['last_updated'], date('d.m.Y', strtotime($trans['updated_at']))); $hasUpcoming = !$isPreview && !empty($trans['scheduled_at']); ?> <?= htmlspecialchars($trans['title']) ?><?= $isPreview ? htmlspecialchars($i18n['preview_suffix']) : '' ?> - <?= htmlspecialchars($project['name']) ?>
' . date('d.m.Y \u\m H:i', strtotime($trans['scheduled_at'])) . '') ?>
' . date('d.m.Y', strtotime($trans['scheduled_at'])) . '') ?>

prepare("SELECT d.id, dt.slug as default_slug, dt.title as fallback_title FROM documents d JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ?"); $stmt->execute([$project['id']]); $documents = $stmt->fetchAll(); $stmt = $db->prepare(" SELECT t.document_id, t.lang, t.title FROM translations t JOIN documents d ON t.document_id = d.id WHERE d.project_id = ? AND t.status = 'published' "); $stmt->execute([$project['id']]); $translationsByDoc = []; foreach ($stmt->fetchAll() as $t) { $translationsByDoc[$t['document_id']][] = $t; } $docs = []; foreach ($documents as $document) { $candidates = $translationsByDoc[$document['id']] ?? []; $best = pick_fallback_translation($candidates, $lang, $primaryLang); $docs[] = [ 'title' => $best['title'] ?? null, 'default_slug' => $document['default_slug'], 'fallback_title' => $document['fallback_title'], 'is_fallback' => $best !== null && $best['lang'] !== $lang, 'fallback_lang' => $best['lang'] ?? null, ]; } ?> <?= htmlspecialchars($i18n['overview_title']) ?> - <?= htmlspecialchars($project['name']) ?>

$meta) { $qs['locale'] = $code; $href = htmlspecialchars('?' . http_build_query($qs)); $label = htmlspecialchars(($meta['flag'] ?? '') . ' ' . ($meta['label'] ?? strtoupper($code))); $active = $code === $current ? ' style="font-weight:700;text-decoration:underline"' : ''; $links[] = '' . $label . ''; } if (empty($links)) return ''; return '
' . htmlspecialchars(t('public.locale_switch_label')) . ': ' . implode(' ', $links) . '
'; } function render_public_404(array $project, string $lang): void { $i18n = get_i18n_strings($lang); ?> 404 - <?= htmlspecialchars($i18n['not_found_title']) ?>

←

t('public.embed.loading_title'), 'close' => t('public.embed.close_label'), 'loadingBody' => t('public.embed.loading_body'), 'error' => t('public.embed.error_title'), 'errorBody' => t('public.embed.error_body'), ], JSON_UNESCAPED_UNICODE); ?> (function () { var i18n = ; var scriptEl = document.currentScript; if (!scriptEl) { var scripts = document.getElementsByTagName('script'); scriptEl = scripts[scripts.length - 1]; } var origin; try { origin = new URL(scriptEl.src, window.location.href).origin; } catch (e) { origin = window.location.origin; } function injectStyles() { if (document.getElementById('paragrafy-embed-style')) return; var style = document.createElement('style'); style.id = 'paragrafy-embed-style'; style.textContent = '.paragrafy-embed-backdrop{position:fixed;inset:0;background:rgba(20,16,22,.5);z-index:999999;display:flex;align-items:flex-end;justify-content:center;padding:0}' + '@media (min-width:720px){.paragrafy-embed-backdrop{align-items:center;padding:20px}}' + '.paragrafy-embed-sheet{background:#fff;width:100%;max-width:640px;max-height:85vh;border-radius:16px 16px 0 0;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 -10px 40px rgba(0,0,0,.2);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}' + '@media (min-width:720px){.paragrafy-embed-sheet{border-radius:16px}}' + '.paragrafy-embed-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #E8E4DC;flex-shrink:0}' + '.paragrafy-embed-header h2{margin:0;font-size:16px;font-weight:700;color:#201C24}' + '.paragrafy-embed-close{border:none;background:transparent;font-size:22px;line-height:1;cursor:pointer;color:#746E78;padding:4px;flex-shrink:0}' + '.paragrafy-embed-body{padding:20px;overflow-y:auto;font-size:14px;line-height:1.7;color:#201C24}' + '.paragrafy-embed-body h2{font-size:18px;margin-top:20px}' + '.paragrafy-embed-body h3{font-size:15px;margin-top:16px}' + '.paragrafy-embed-body p{margin:0.75em 0}' + '.paragrafy-embed-loading,.paragrafy-embed-error{padding:40px 20px;text-align:center;color:#746E78;font-size:14px}'; document.head.appendChild(style); } function openDrawer(lang, slug) { injectStyles(); var backdrop = document.createElement('div'); backdrop.className = 'paragrafy-embed-backdrop'; backdrop.innerHTML = ''; document.body.appendChild(backdrop); function close() { backdrop.remove(); document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') close(); } document.addEventListener('keydown', onKey); backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); }); backdrop.querySelector('.paragrafy-embed-close').addEventListener('click', close); fetch(origin + '/api/' + encodeURIComponent(lang) + '/' + encodeURIComponent(slug)) .then(function (res) { if (!res.ok) throw new Error('not_found'); return res.json(); }) .then(function (data) { backdrop.querySelector('.paragrafy-embed-header h2').textContent = data.title || ''; backdrop.querySelector('.paragrafy-embed-body').innerHTML = data.html || ''; }) .catch(function () { backdrop.querySelector('.paragrafy-embed-header h2').textContent = i18n.error; backdrop.querySelector('.paragrafy-embed-body').innerHTML = '
' + i18n.errorBody + '
'; }); } function bind(el) { if (el.__paragrafyBound) return; el.__paragrafyBound = true; el.addEventListener('click', function (e) { e.preventDefault(); var slug = el.getAttribute('data-paragrafy-slug'); var lang = el.getAttribute('data-paragrafy-lang') || 'de'; if (slug) openDrawer(lang, slug); }); } function scan() { var els = document.querySelectorAll('[data-paragrafy-slug]'); for (var i = 0; i < els.length; i++) bind(els[i]); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', scan); } else { scan(); } if (window.MutationObserver) { new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true }); } })(); prepare(" SELECT t.slug, dt.slug AS default_slug FROM translations t JOIN documents d ON t.document_id = d.id JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ? AND t.lang = ? AND dt.slug = 'datenschutz' AND t.status = 'published' LIMIT 1 "); $stmt->execute([$project['id'], $primaryLang]); $privacyDoc = $stmt->fetch(); if ($privacyDoc) { $privacySlug = $privacyDoc['slug'] ?: $privacyDoc['default_slug']; } } $privacyUrl = $scheme . '://' . $selfHost . '/' . $primaryLang . '/' . $privacySlug; $brandJs = json_encode($brand, JSON_UNESCAPED_UNICODE); $textJs = json_encode($bannerText, JSON_UNESCAPED_UNICODE); $privacyUrlJs = json_encode($privacyUrl, JSON_UNESCAPED_UNICODE); $loggingEnabledJs = json_encode(!empty($project['consent_logging_enabled'])); $bannerTextHashJs = json_encode(hash('sha256', $bannerText)); $i18nJs = json_encode([ 'learnMore' => t('public.consent.learn_more'), 'decline' => t('public.consent.decline'), 'accept' => t('public.consent.accept'), 'ariaLabel' => t('public.consent.aria_label'), ], JSON_UNESCAPED_UNICODE); ?> (function () { var CONSENT_KEY = 'paragrafy_consent'; var CONSENT_ID_KEY = 'paragrafy_consent_id'; try { if (localStorage.getItem(CONSENT_KEY)) return; } catch (e) {} var brand = ; var text = ; var privacyUrl = ; var i18n = ; var loggingEnabled = ; var bannerTextHash = ; var origin; try { origin = new URL((document.currentScript || {}).src || '', window.location.href).origin; } catch (e) { origin = window.location.origin; } var style = document.createElement('style'); style.textContent = '.paragrafy-consent-bar{position:fixed;left:16px;right:16px;bottom:16px;max-width:560px;margin:0 auto;background:#201C24;color:#fff;border-radius:12px;padding:16px 18px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;line-height:1.5;z-index:999998;box-shadow:0 10px 40px rgba(0,0,0,.25);display:flex;flex-direction:column;gap:12px}' + '.paragrafy-consent-bar a{color:#fff;text-decoration:underline}' + '.paragrafy-consent-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}' + '.paragrafy-consent-actions button{border:none;border-radius:8px;padding:8px 14px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}' + '.paragrafy-consent-decline{background:transparent !important;color:#fff;border:1px solid rgba(255,255,255,.3) !important}' + '.paragrafy-consent-accept{background:' + brand + ';color:#fff}'; document.head.appendChild(style); var bar = document.createElement('div'); bar.className = 'paragrafy-consent-bar'; bar.setAttribute('role', 'dialog'); bar.setAttribute('aria-label', i18n.ariaLabel); var textEl = document.createElement('div'); textEl.textContent = text + ' '; var link = document.createElement('a'); link.href = privacyUrl; link.target = '_blank'; link.rel = 'noopener'; link.textContent = i18n.learnMore; textEl.appendChild(link); var actions = document.createElement('div'); actions.className = 'paragrafy-consent-actions'; var declineBtn = document.createElement('button'); declineBtn.type = 'button'; declineBtn.className = 'paragrafy-consent-decline'; declineBtn.textContent = i18n.decline; var acceptBtn = document.createElement('button'); acceptBtn.type = 'button'; acceptBtn.className = 'paragrafy-consent-accept'; acceptBtn.textContent = i18n.accept; actions.appendChild(declineBtn); actions.appendChild(acceptBtn); bar.appendChild(textEl); bar.appendChild(actions); document.body.appendChild(bar); function setConsent(value) { try { localStorage.setItem(CONSENT_KEY, value); } catch (e) {} bar.remove(); document.dispatchEvent(new CustomEvent('paragrafy:consent', { detail: { consent: value } })); if (loggingEnabled) { var consentId = ''; try { consentId = localStorage.getItem(CONSENT_ID_KEY) || ''; } catch (e) {} if (!consentId) { consentId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() : ('cid-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)); try { localStorage.setItem(CONSENT_ID_KEY, consentId); } catch (e) {} } try { fetch(origin + '/api/consent-log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: value, lang: navigator.language || '', textHash: bannerTextHash, consentId: consentId }), keepalive: true }).catch(function () {}); } catch (e) {} } } acceptBtn.addEventListener('click', function () { setConsent('accepted'); }); declineBtn.addEventListener('click', function () { setConsent('declined'); }); })();