community und dsgvo
All checks were successful
Deploy / deploy (push) Successful in 51s

This commit is contained in:
2026-07-22 21:29:50 +02:00
parent 751b6996c4
commit eb16827fdf
7 changed files with 304 additions and 45 deletions

View File

@@ -3,6 +3,7 @@
<nav class="site-footer__links" aria-label="Footer">
<a href="/impressum">Impressum</a>
<a href="/ueber-uns">Über uns</a>
<button class="site-footer__link-button" type="button" data-consent-open>Cookie-Einstellungen</button>
</nav>
<p class="site-footer__copy">© Copyright <?= date('Y') ?> - Lars Gebhardt-Kusche</p>
</div>

View File

@@ -70,8 +70,93 @@ if ($debugEnabled) {
<?php asset_scripts('header'); ?>
</head>
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>" data-location-preference="<?= htmlspecialchars($locationTrackingPreference, ENT_QUOTES) ?>">
<script>
(function () {
const storageKey = 'pkt_cookie_consent';
const read = () => {
try {
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
} catch (err) {
return null;
}
};
window.PKTConsent = window.PKTConsent || {
storageKey,
get() {
return read();
},
has(category) {
if (category === 'necessary') return true;
const consent = read();
return Boolean(consent && consent[category] === true);
},
openPreferences() {
document.dispatchEvent(new CustomEvent('pkt:open-consent'));
},
};
})();
</script>
<?php tpl('matomo', 'structure'); ?>
<?php tpl('nav', 'structure'); ?>
<div class="cookie-consent" id="cookieConsentBanner" hidden>
<div class="cookie-consent__inner">
<div class="cookie-consent__copy">
<strong>Cookies, Analyse und externe Dienste</strong>
<p>Notwendige Cookies für Sitzung und Login sind immer aktiv. Analyse mit Matomo sowie Karten-, Standort- und Geocoding-Funktionen werden erst nach deiner Auswahl geladen.</p>
</div>
<div class="cookie-consent__actions">
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
<button class="btn ghost" type="button" data-consent-open>Auswahl</button>
<button class="btn" type="button" data-consent-accept-all>Alle akzeptieren</button>
</div>
</div>
</div>
<div class="modal" id="cookieConsentModal">
<div class="panel">
<div class="head flex between center-y">
<h3 style="margin:0;">Cookie- und Diensteinstellungen</h3>
<button class="btn ghost" type="button" data-consent-close>✕</button>
</div>
<div class="stack gap-12" style="margin-top:12px;">
<p class="muted" style="margin:0;">Du kannst nicht notwendige Funktionen freiwillig aktivieren oder später wieder widerrufen.</p>
<div class="card">
<strong>Notwendig</strong>
<p class="muted small">Sitzung, Login und Sicherheitsfunktionen. Immer aktiv.</p>
</div>
<label class="card" style="display:block; cursor:pointer;">
<input type="checkbox" id="consentAnalytics" style="margin-right:8px;">
<strong>Analyse mit Matomo</strong>
<p class="muted small" style="margin:8px 0 0;">Reichweitenmessung und Nutzungsanalyse.</p>
</label>
<label class="card" style="display:block; cursor:pointer;">
<input type="checkbox" id="consentExternalServices" style="margin-right:8px;">
<strong>Externe Dienste, Karten und Standort</strong>
<p class="muted small" style="margin:8px 0 0;">Browser-Geolocation, lokale Standortspeicherung, Leaflet von `unpkg.com` und Geocoding/Reverse-Geocoding über OpenStreetMap Nominatim.</p>
</label>
<div class="flex gap-12" style="flex-wrap:wrap;">
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
<button class="btn" type="button" data-consent-save>Auswahl speichern</button>
</div>
</div>
</div>
</div>
<div class="modal" id="locationPreferenceModal">
<div class="panel">
<div class="head flex between center-y">
<h3 style="margin:0;">Standortfreigabe</h3>
<button class="btn ghost" type="button" data-location-pref-close>✕</button>
</div>
<div class="stack gap-12" style="margin-top:12px;">
<p class="muted" style="margin:0;">Damit wir dir passende Treffen in deiner Nähe zeigen können, dürfen wir deinen Standort verwenden. Wie soll das gehandhabt werden?</p>
<div class="stack gap-10">
<button class="btn" type="button" data-location-pref-choice="session">Nur für diesen Besuch</button>
<button class="btn ghost" type="button" data-location-pref-choice="enabled">Immer erlauben</button>
<button class="btn ghost" type="button" data-location-pref-choice="disabled">Niemals</button>
</div>
</div>
</div>
</div>
<?php if ($debugEnabled): ?>
<style>

View File

@@ -1,40 +1,15 @@
<?php if (!defined('MATOMO_SITE_ID')) return; ?>
<?php if (!defined('MATOMO_ENABLED') || !MATOMO_ENABLED) return; ?>
<?php
$primaryDomain = app_primary_domain();
$primaryDomain = app_primary_domain();
$fakecheckDomain = app_fakecheck_domain();
// Build allowed domains for tracking (hosts only, no paths)
$matomoDomains = array_values(array_unique(array_filter([
$primaryDomain ? '*.' . $primaryDomain : null,
($fakecheckDomain && $fakecheckDomain !== $primaryDomain) ? '*.' . $fakecheckDomain : null,
])));
?>
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
_paq.push(["setDomains", <?= json_encode($matomoDomains, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>]);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "<?= rtrim(MATOMO_URL, '/') ?>/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '<?= MATOMO_SITE_ID ?>']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.async=true;
g.src=u + 'matomo.js';
s.parentNode.insertBefore(g,s);
})();
</script>
<noscript>
<p>
<img referrerpolicy="no-referrer-when-downgrade"
src="<?= rtrim(MATOMO_URL,'/') ?>/matomo.php?idsite=<?= MATOMO_SITE_ID ?>&amp;rec=1"
style="border:0;" alt="" />
</p>
</noscript>
<!-- End Matomo -->
<script type="application/json" id="matomoConfig"><?= json_encode([
'url' => rtrim(MATOMO_URL, '/') . '/',
'siteId' => (string) MATOMO_SITE_ID,
'domains' => $matomoDomains,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>

View File

@@ -137,7 +137,14 @@ body {
.site-footer__inner { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px 30px; }
.site-footer__links { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; font-weight: 600; }
.site-footer__links a:hover { color: var(--color-primary); }
.site-footer__link-button { border: 0; background: transparent; padding: 0; font: inherit; color: var(--color-text); cursor: pointer; }
.site-footer__link-button:hover { color: var(--color-primary); }
.site-footer__copy { margin: 0; color: var(--color-muted); font-size: 14px; text-align: center; }
.cookie-consent { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 220; }
.cookie-consent[hidden] { display: none; }
.cookie-consent__inner { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px 20px; background: rgba(255,255,255,0.98); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: 0 18px 40px rgba(0,0,0,0.16); backdrop-filter: blur(10px); }
.cookie-consent__copy p { margin: 6px 0 0; color: var(--color-muted); }
.cookie-consent__actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.legal-page { min-height: 40vh; }
.content-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 24px; box-shadow: var(--shadow-card); }
.content-card--narrow { max-width: 860px; margin: 0 auto; }
@@ -279,6 +286,8 @@ body {
.forum-admin-item { padding-left: 16px; padding-right: 16px; }
.forum-post__body,
.forum-reply-form { padding: 18px 16px; }
.cookie-consent__inner { grid-template-columns: 1fr; }
.cookie-consent__actions { justify-content: stretch; }
}
/* Auth & Dashboard */

View File

@@ -55,6 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
const threads = Array.isArray(window.__threads) ? window.__threads : [];
const EVENTS_RADIUS_KM = 20;
const LOCATION_STORAGE_KEY = 'pkt_user_location';
const LOCATION_SESSION_STORAGE_KEY = 'pkt_user_location_session';
const LOCATION_SESSION_PREFERENCE_KEY = 'pkt_user_location_session_preference';
const el = {
sliderTrack: document.getElementById('eventSlider'),
@@ -75,7 +77,9 @@ document.addEventListener('DOMContentLoaded', () => {
quickLng: document.getElementById('qsLng'),
quickGeo: document.getElementById('quickGeo'),
eventsSection: document.getElementById('events'),
locationPreferenceModal: document.getElementById('locationPreferenceModal'),
};
let effectiveLocationPreference = locationPreference;
const fmtDate = (iso) => {
const d = new Date(iso);
@@ -98,7 +102,7 @@ document.addEventListener('DOMContentLoaded', () => {
?.slice(prefix.length) || '';
};
const persistLocation = (location) => {
const persistLocation = (location, mode = 'persistent') => {
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) return;
const payload = {
lat: Number(location.lat.toFixed(6)),
@@ -106,12 +110,20 @@ document.addEventListener('DOMContentLoaded', () => {
label: location.label || 'Mein Standort',
savedAt: new Date().toISOString(),
};
try {
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
if (mode === 'session') {
try {
window.sessionStorage.setItem(LOCATION_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
} else {
try {
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
} catch (err) {
// ignore
}
setCookie('pkt_user_location', JSON.stringify(payload), 30);
}
setCookie('pkt_user_location', JSON.stringify(payload), 30);
if (el.quickLat) el.quickLat.value = payload.lat.toFixed(6);
if (el.quickLng) el.quickLng.value = payload.lng.toFixed(6);
if (el.quickLoc && (!el.quickLoc.value || el.quickLoc.value === 'Mein Standort')) {
@@ -125,6 +137,11 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (err) {
// ignore
}
try {
window.sessionStorage.removeItem(LOCATION_SESSION_STORAGE_KEY);
} catch (err) {
// ignore
}
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
if (el.quickLat) el.quickLat.value = '';
if (el.quickLng) el.quickLng.value = '';
@@ -133,6 +150,12 @@ document.addEventListener('DOMContentLoaded', () => {
const getStoredLocation = () => {
const candidates = [];
try {
const session = window.sessionStorage.getItem(LOCATION_SESSION_STORAGE_KEY);
if (session) candidates.push(session);
} catch (err) {
// ignore
}
try {
const local = window.localStorage.getItem(LOCATION_STORAGE_KEY);
if (local) candidates.push(local);
@@ -333,7 +356,7 @@ document.addEventListener('DOMContentLoaded', () => {
lng: pos.coords.longitude,
label: 'Mein Standort',
};
persistLocation(location);
persistLocation(location, options.persistMode || 'persistent');
resolve(location);
},
() => resolve(null),
@@ -345,19 +368,133 @@ document.addEventListener('DOMContentLoaded', () => {
);
});
const closeLocationPreferenceModal = () => {
el.locationPreferenceModal?.classList.remove('open');
};
document.querySelectorAll('[data-location-pref-close]').forEach(btn => {
btn.addEventListener('click', closeLocationPreferenceModal);
});
el.locationPreferenceModal?.addEventListener('click', (event) => {
if (event.target === el.locationPreferenceModal) {
closeLocationPreferenceModal();
}
});
const saveLocationPreference = async (preference) => {
effectiveLocationPreference = preference;
if (!isLoggedIn) {
try {
window.localStorage.setItem('pkt_location_preference_guest', preference);
} catch (err) {
// ignore
}
return true;
}
try {
const body = new URLSearchParams({ preference });
const response = await fetch('/api/location-preference', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: body.toString(),
});
return response.ok;
} catch (err) {
return false;
}
};
const getSessionPreference = () => {
try {
return window.sessionStorage.getItem(LOCATION_SESSION_PREFERENCE_KEY);
} catch (err) {
return null;
}
};
const setSessionPreference = (value) => {
try {
if (!value) {
window.sessionStorage.removeItem(LOCATION_SESSION_PREFERENCE_KEY);
} else {
window.sessionStorage.setItem(LOCATION_SESSION_PREFERENCE_KEY, value);
}
} catch (err) {
// ignore
}
};
const resolvePromptChoice = () => new Promise((resolve) => {
if (!el.locationPreferenceModal) {
resolve('disabled');
return;
}
const buttons = el.locationPreferenceModal.querySelectorAll('[data-location-pref-choice]');
const cleanup = () => {
buttons.forEach(button => button.removeEventListener('click', handleClick));
document.removeEventListener('keydown', handleKeydown);
};
const finish = (value) => {
cleanup();
closeLocationPreferenceModal();
resolve(value);
};
const handleClick = (event) => {
const value = event.currentTarget.getAttribute('data-location-pref-choice') || 'disabled';
finish(value);
};
const handleKeydown = (event) => {
if (event.key === 'Escape') {
finish('disabled');
}
};
buttons.forEach(button => button.addEventListener('click', handleClick));
document.addEventListener('keydown', handleKeydown);
el.locationPreferenceModal.classList.add('open');
});
const ensureEventsLocation = async () => {
if (locationPreference === 'disabled') {
const sessionPreference = getSessionPreference();
if (effectiveLocationPreference === 'disabled') {
clearStoredLocation();
renderSlider(null);
return null;
}
const stored = applyStoredLocationToForm();
if (stored && locationPreference !== 'enabled') {
if (stored && effectiveLocationPreference !== 'enabled' && sessionPreference !== 'session') {
renderSlider(stored);
return stored;
}
const location = await requestLocation();
if (effectiveLocationPreference === 'prompt') {
const choice = sessionPreference || await resolvePromptChoice();
if (choice === 'session') {
setSessionPreference('session');
const location = await requestLocation({ persistMode: 'session' });
renderSlider(location || stored);
return location;
}
if (choice === 'disabled') {
await saveLocationPreference('disabled');
clearStoredLocation();
renderSlider(null);
return null;
}
if (choice === 'enabled') {
await saveLocationPreference('enabled');
effectiveLocationPreference = 'enabled';
}
}
const location = await requestLocation({
persistMode: effectiveLocationPreference === 'enabled' ? 'persistent' : 'session',
});
renderSlider(location || stored);
return location;
};
@@ -384,7 +521,7 @@ document.addEventListener('DOMContentLoaded', () => {
lat: pos.coords.latitude,
lng: pos.coords.longitude,
label: 'Mein Standort',
});
}, effectiveLocationPreference === 'enabled' ? 'persistent' : 'session');
renderSlider(getStoredLocation());
},
() => alert('Standort konnte nicht ermittelt werden.')
@@ -426,14 +563,25 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
const initialLocation = locationPreference === 'disabled' ? null : applyStoredLocationToForm();
if (locationPreference === 'disabled') {
try {
if (!isLoggedIn) {
const guestPreference = window.localStorage.getItem('pkt_location_preference_guest');
if (guestPreference && ['disabled', 'prompt', 'enabled'].includes(guestPreference)) {
effectiveLocationPreference = guestPreference;
}
}
} catch (err) {
// ignore
}
const initialLocation = effectiveLocationPreference === 'disabled' ? null : applyStoredLocationToForm();
if (effectiveLocationPreference === 'disabled') {
clearStoredLocation();
}
renderSlider(initialLocation);
renderThreadSlider();
if (locationPreference === 'enabled' && window.location.pathname === '/') {
if (effectiveLocationPreference === 'enabled' && window.location.pathname === '/') {
ensureEventsLocation();
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
ensureEventsLocation();

View File

@@ -68,6 +68,9 @@ $targetReal = realpath($target);
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
$skipLayout = true;
}
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
$skipLayout = true;
}
// ------------------------------------
// Ausgabe

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'method_not_allowed']);
return;
}
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
return;
}
$preference = (string)($_POST['preference'] ?? '');
if (!in_array($preference, ['disabled', 'prompt', 'enabled'], true)) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => 'invalid_preference']);
return;
}
try {
$pdo = app()->pdo();
if (!$pdo) {
throw new RuntimeException('db_unavailable');
}
$settings = new \App\ProfileSettings($pdo);
$settings->updateLocationTrackingPreference((int)$_SESSION['user_id'], $preference);
echo json_encode(['ok' => true, 'preference' => $preference]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'save_failed']);
}