last change
All checks were successful
Deploy / deploy (push) Successful in 56s

This commit is contained in:
2026-08-07 23:43:36 +02:00
parent c9b1839460
commit a102b2aa0c
15 changed files with 486 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
- Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert. - Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert.
- Profiladresse kann per Suche oder Browser-Standort übernommen werden. - Profiladresse kann per Suche oder Browser-Standort übernommen werden.
- Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt. - Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt.
- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen.
- Orte und Veranstaltungen als Ausflugsziele sind vorerst in einen separaten Mitgliederbereichspunkt `Ausflugsziele` verschoben. - Orte und Veranstaltungen als Ausflugsziele sind vorerst in einen separaten Mitgliederbereichspunkt `Ausflugsziele` verschoben.
- Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder. - Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder.
- Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt. - Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt.

View File

@@ -18,6 +18,7 @@ Stand: 2026-08-05
- Consent- und rechtliche Texte: `public/page/datenschutz.php` - Consent- und rechtliche Texte: `public/page/datenschutz.php`
- System-Einstellungen: `src/App/SystemSettings.php` - System-Einstellungen: `src/App/SystemSettings.php`
- neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php`
- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php`
- UI für getrennte Bereiche `Events` und `Ausflugsziele` im Mitgliederbereich: `partials/landing/account/dashboard.php` - UI für getrennte Bereiche `Events` und `Ausflugsziele` im Mitgliederbereich: `partials/landing/account/dashboard.php`
- Preis-, Bild- und Ortseingabe für Ausflugsziele sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php` - Preis-, Bild- und Ortseingabe für Ausflugsziele sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php`
- Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder - Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder

View File

@@ -27,6 +27,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
- Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Ausflugsziele` verschoben - Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Ausflugsziele` verschoben
- Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen - Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen
- Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden - Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden
- Im Bereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
## Wording-Regel ## Wording-Regel
- Primärbegriff im Produkt: `Events` - Primärbegriff im Produkt: `Events`
@@ -44,6 +45,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
## Datenschutz und Sicherheit ## Datenschutz und Sicherheit
- Passwort-Hashes über Argon2id - Passwort-Hashes über Argon2id
- Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash - Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash
- persönliche Kalender-Feed-Tokens verschlüsselt plus separater SHA-256-Lookup-Hash
- Profilfelder wie Vorname, Nachname, Straße/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung verschlüsselt - Profilfelder wie Vorname, Nachname, Straße/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung verschlüsselt
- Standortkoordinaten (`lat`, `lng`) bleiben für Distanz- und Umkreissuchen technisch im Klartext - Standortkoordinaten (`lat`, `lng`) bleiben für Distanz- und Umkreissuchen technisch im Klartext
- nicht notwendige Analyse- und Drittanbieterfunktionen erst nach Einwilligung aktiv - nicht notwendige Analyse- und Drittanbieterfunktionen erst nach Einwilligung aktiv

View File

@@ -22,6 +22,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events
- Account email is encrypted application-side and addressed through a separate HMAC lookup hash. - Account email is encrypted application-side and addressed through a separate HMAC lookup hash.
- Profile address can be completed via address search or browser-based location import. - Profile address can be completed via address search or browser-based location import.
- The `Events` area is limited again to real own events and event participations. - The `Events` area is limited again to real own events and event participations.
- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations.
- Places and event-like outing targets were moved for now into a separate member-area section `Outings`. - Places and event-like outing targets were moved for now into a separate member-area section `Outings`.
- The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events. - The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events.
- Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed. - Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed.

View File

@@ -18,6 +18,7 @@ Updated: 2026-08-05
- consent and legal texts: `public/page/datenschutz.php` - consent and legal texts: `public/page/datenschutz.php`
- system settings: `src/App/SystemSettings.php` - system settings: `src/App/SystemSettings.php`
- new listing/place base: `src/App/ListingCatalog.php` - new listing/place base: `src/App/ListingCatalog.php`
- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php`
- separated `Events` and `Outings` UI in member area: `partials/landing/account/dashboard.php` - separated `Events` and `Outings` UI in member area: `partials/landing/account/dashboard.php`
- pricing, image upload, and place/address input for outings plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php` - pricing, image upload, and place/address input for outings plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php`
- legacy own events were extended in `src/App/AccountPages.php` and `schema.sql` with category and image fields - legacy own events were extended in `src/App/AccountPages.php` and `schema.sql` with category and image fields

View File

@@ -27,6 +27,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
- places and other events were moved for now into a separate member-area section `Outings` - places and other events were moved for now into a separate member-area section `Outings`
- the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections - the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections
- categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area - categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area
- the `Events` area now also includes an ICS download and a personal subscribable calendar feed for all own events and event participations
## Core Wording Rule ## Core Wording Rule
- Primary product term: `Events` - Primary product term: `Events`
@@ -43,6 +44,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app
## Privacy and Security ## Privacy and Security
- password hashes via Argon2id - password hashes via Argon2id
- account emails are encrypted and additionally addressed through a separate HMAC lookup hash - account emails are encrypted and additionally addressed through a separate HMAC lookup hash
- personal calendar-feed tokens are stored encrypted and additionally resolved through a separate SHA-256 lookup hash
- profile fields such as first name, last name, street/house number, phone number, profession, languages, and short bio are encrypted - profile fields such as first name, last name, street/house number, phone number, profession, languages, and short bio are encrypted
- location coordinates (`lat`, `lng`) remain plaintext where technically required for distance and radius search - location coordinates (`lat`, `lng`) remain plaintext where technically required for distance and radius search
- non-essential analytics and third-party services are only active after consent - non-essential analytics and third-party services are only active after consent

View File

@@ -41,10 +41,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf:
- Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung - Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung
- Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen - Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen
- Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar - Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar
- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen
## Datenschutz und Sicherheit ## Datenschutz und Sicherheit
- sensible Profilfelder werden app-seitig verschlüsselt gespeichert - sensible Profilfelder werden app-seitig verschlüsselt gespeichert
- Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert - Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert
- persönliche Kalender-Feed-Tokens werden verschlüsselt gespeichert und zusätzlich über einen separaten SHA-256-Lookup-Hash adressiert
- Standortkoordinaten bleiben technisch im Klartext, soweit sie für Distanz- und Umkreissuchen erforderlich sind - Standortkoordinaten bleiben technisch im Klartext, soweit sie für Distanz- und Umkreissuchen erforderlich sind
- Änderungen an Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern erfordern immer auch die Prüfung und Aktualisierung von Consent und rechtlichen Hinweisen - Änderungen an Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern erfordern immer auch die Prüfung und Aktualisierung von Consent und rechtlichen Hinweisen

View File

@@ -284,7 +284,22 @@ if (!empty($canManageSystemSettings)) {
<div class="account-panel__body"> <div class="account-panel__body">
<div class="flex gap-12" style="margin:0 0 16px 0; flex-wrap: wrap;"> <div class="flex gap-12" style="margin:0 0 16px 0; flex-wrap: wrap;">
<button class="btn" type="button" data-modal-open="modalEvent">Eigenes Event anlegen</button> <button class="btn" type="button" data-modal-open="modalEvent">Eigenes Event anlegen</button>
<?php if (!empty($calendarExportUrl)): ?>
<a class="btn ghost" href="<?= htmlspecialchars((string)$calendarExportUrl, ENT_QUOTES) ?>">ICS herunterladen</a>
<?php endif; ?>
</div> </div>
<?php if (!empty($calendarExportUrl) && !empty($calendarFeedUrl)): ?>
<div class="card" style="margin-bottom:18px;">
<strong>Kalender-Synchronisierung</strong>
<p class="muted small" style="margin:8px 0 0;">Exportiert werden immer alle eigenen Events und alle Events, an denen du teilnimmst.</p>
<p class="muted small" style="margin:8px 0 0;">„ICS herunterladen“ ist nur eine Momentaufnahme für den manuellen Import. Änderungen, Absagen oder Löschungen in Papa-Kind-Treff werden dadurch später nicht automatisch aus deinem privaten Kalender entfernt.</p>
<p class="muted small" style="margin:8px 0 0;">Für automatische Aktualisierungen nutze den persönlichen Kalender-Feed. Dann können unterstützte Kalender gelöschte oder abgesagte Einträge bei der nächsten Synchronisierung übernehmen.</p>
<div class="stack gap-6" style="margin-top:12px;">
<label class="label" for="calendarFeedUrl">Persönlicher Kalender-Feed</label>
<input id="calendarFeedUrl" class="input" type="text" readonly value="<?= htmlspecialchars((string)$calendarFeedUrl, ENT_QUOTES) ?>">
</div>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:18px;"> <div class="card" style="margin-bottom:18px;">
<strong>Eigene Events</strong> <strong>Eigene Events</strong>
<p class="muted small" style="margin:8px 0 0;">Von dir organisierte Treffen oder Events mit eigener Teilnahme-Logik.</p> <p class="muted small" style="margin:8px 0 0;">Von dir organisierte Treffen oder Events mit eigener Teilnahme-Logik.</p>

View File

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

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Login erforderlich.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->ensureSchema();
$userId = (int)$_SESSION['user_id'];
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="papa-kind-treff-events.ics"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
echo $ics;

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
$token = trim((string)($_GET['token'] ?? ''));
if ($token === '') {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'Token fehlt.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->ensureSchema();
$userId = $calendarSync->findUserIdByFeedToken($token);
if (!$userId) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'Kalender-Feed nicht gefunden.';
return;
}
$calendarSync->touchFeedAccess($userId);
$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events');
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: inline; filename="papa-kind-treff-events-feed.ics"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
echo $ics;

View File

@@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
<main class="container section legal-page"> <main class="container section legal-page">
<div class="content-card content-card--narrow page-copy"> <div class="content-card content-card--narrow page-copy">
<h1>Datenschutz &amp; Cookies</h1> <h1>Datenschutz &amp; Cookies</h1>
<p class="muted small">Stand: 4. August 2026</p> <p class="muted small">Stand: 5. August 2026</p>
<p> <p>
Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff
@@ -62,6 +62,12 @@ $clientCookie = $config->cookiePrefix() . 'client';
Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der
Anwendung verschlüsselt gespeichert und verarbeitet. Anwendung verschlüsselt gespeichert und verarbeitet.
</p> </p>
<p>
Wenn du deine Events in einen privaten Kalender exportierst oder einen persönlichen Kalender-Feed abonnierst,
werden dabei ausschließlich deine eigenen Events und deine Event-Teilnahmen als Kalenderdaten bereitgestellt.
Der abonnierbare Feed ist über eine persönliche, nicht öffentliche URL abgesichert. Diese URL sollte vertraulich
behandelt und nicht an Dritte weitergegeben werden.
</p>
<p> <p>
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des
Nutzungsverhältnisses erforderlich ist. Nutzungsverhältnisses erforderlich ist.

View File

@@ -424,6 +424,17 @@ CREATE TABLE user_tokens (
INDEX idx_ut_type (type) INDEX idx_ut_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE user_calendar_feeds (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NULL,
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit-Log für wichtige Aktionen -- Audit-Log für wichtige Aktionen
CREATE TABLE audit_log ( CREATE TABLE audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

View File

@@ -176,6 +176,7 @@ final class AccountPages
$profileSettings = $pdo ? new ProfileSettings($pdo) : null; $profileSettings = $pdo ? new ProfileSettings($pdo) : null;
$systemSettings = $pdo ? new SystemSettings($pdo) : null; $systemSettings = $pdo ? new SystemSettings($pdo) : null;
$listingCatalog = $pdo ? new ListingCatalog($pdo) : null; $listingCatalog = $pdo ? new ListingCatalog($pdo) : null;
$calendarSync = $pdo ? new CalendarSync($app) : null;
$section = (string)($_GET['section'] ?? 'profile'); $section = (string)($_GET['section'] ?? 'profile');
$canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false;
$allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings']; $allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings'];
@@ -189,6 +190,9 @@ final class AccountPages
if ($listingCatalog) { if ($listingCatalog) {
$listingCatalog->ensureSchema(); $listingCatalog->ensureSchema();
} }
if ($calendarSync) {
$calendarSync->ensureSchema();
}
if ($pdo) { if ($pdo) {
self::ensureLegacyEventSchema($pdo); self::ensureLegacyEventSchema($pdo);
} }
@@ -785,6 +789,13 @@ final class AccountPages
$section = 'profile'; $section = 'profile';
} }
$avatarBuilder = AvatarManager::builderStyles($profile); $avatarBuilder = AvatarManager::builderStyles($profile);
$calendarExportUrl = null;
$calendarFeedUrl = null;
if ($calendarSync) {
$calendarToken = $calendarSync->getOrCreateFeedToken($userId);
$calendarExportUrl = CalendarSync::buildAbsoluteUrl('/calendar/export');
$calendarFeedUrl = CalendarSync::buildAbsoluteUrl('/calendar/feed?token=' . rawurlencode($calendarToken));
}
return compact( return compact(
'flash', 'flash',
@@ -813,6 +824,8 @@ final class AccountPages
'systemSettingsValues', 'systemSettingsValues',
'listingCatalogStatus', 'listingCatalogStatus',
'avatarBuilder', 'avatarBuilder',
'calendarExportUrl',
'calendarFeedUrl',
'section', 'section',
'allowedSections' 'allowedSections'
); );

366
src/App/CalendarSync.php Normal file
View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App;
final class CalendarSync
{
public function __construct(private App $app)
{
}
public function ensureSchema(): void
{
$this->pdo()->exec(
'CREATE TABLE IF NOT EXISTS user_calendar_feeds (
user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
token_encrypted TEXT NOT NULL,
token_lookup_hash CHAR(64) NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at DATETIME NULL,
CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_calendar_feeds_lookup (token_lookup_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
public function getOrCreateFeedToken(int $userId): string
{
$existing = $this->getFeedToken($userId);
if ($existing !== null) {
return $existing;
}
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$crypto = $this->crypto();
$stmt = $this->pdo()->prepare(
'INSERT INTO user_calendar_feeds (user_id, token_encrypted, token_lookup_hash, created_at, rotated_at)
VALUES (:userId, :tokenEncrypted, :tokenLookupHash, NOW(), NOW())
ON DUPLICATE KEY UPDATE
token_encrypted = VALUES(token_encrypted),
token_lookup_hash = VALUES(token_lookup_hash),
rotated_at = NOW()'
);
$stmt->execute([
'userId' => $userId,
'tokenEncrypted' => $crypto->encrypt($token),
'tokenLookupHash' => hash('sha256', $token),
]);
return $token;
}
public function getFeedToken(int $userId): ?string
{
$stmt = $this->pdo()->prepare('SELECT token_encrypted FROM user_calendar_feeds WHERE user_id = :userId LIMIT 1');
$stmt->execute(['userId' => $userId]);
$encrypted = $stmt->fetchColumn();
if (!is_string($encrypted) || trim($encrypted) === '') {
return null;
}
$token = $this->crypto()->decrypt($encrypted);
return is_string($token) && trim($token) !== '' ? $token : null;
}
public function findUserIdByFeedToken(string $token): ?int
{
$token = trim($token);
if ($token === '') {
return null;
}
$stmt = $this->pdo()->prepare(
'SELECT user_id
FROM user_calendar_feeds
WHERE token_lookup_hash = :tokenLookupHash
LIMIT 1'
);
$stmt->execute(['tokenLookupHash' => hash('sha256', $token)]);
$userId = $stmt->fetchColumn();
return $userId !== false ? (int)$userId : null;
}
public function touchFeedAccess(int $userId): void
{
$stmt = $this->pdo()->prepare(
'UPDATE user_calendar_feeds
SET last_accessed_at = NOW()
WHERE user_id = :userId'
);
$stmt->execute(['userId' => $userId]);
}
public function listCalendarEventsForUser(int $userId): array
{
$stmt = $this->pdo()->prepare(
'SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
"owner" AS relation_type,
NULL AS participation_status,
NULL AS host_name
FROM events e
WHERE e.created_by = :userId
UNION ALL
SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region,
e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids,
"participant" AS relation_type,
ep.status AS participation_status,
COALESCE(up.display_name, "Mitglied") AS host_name
FROM event_participants ep
INNER JOIN events e ON e.id = ep.event_id
INNER JOIN users u ON u.id = e.created_by
LEFT JOIN user_profiles up ON up.user_id = u.id
WHERE ep.user_id = :participantId
AND e.created_by <> :ownerId
AND ep.status <> "cancelled"
ORDER BY starts_at ASC, id ASC'
);
$stmt->execute([
'userId' => $userId,
'participantId' => $userId,
'ownerId' => $userId,
]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function renderUserCalendarIcs(int $userId, string $calendarName = 'Papa-Kind-Treff Events'): string
{
return $this->renderCalendarIcs($calendarName, $this->listCalendarEventsForUser($userId));
}
public function renderCalendarIcs(string $calendarName, array $events): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Papa-Kind-Treff//Kalender//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . $this->escapeText($calendarName),
'X-WR-TIMEZONE:Europe/Berlin',
];
foreach ($events as $event) {
$lines = array_merge($lines, $this->buildEventLines($event));
}
$lines[] = 'END:VCALENDAR';
return $this->foldLines($lines);
}
public static function buildAbsoluteUrl(string $path): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
return $scheme . '://' . $host . $path;
}
private function buildEventLines(array $event): array
{
$lines = [
'BEGIN:VEVENT',
'UID:' . $this->buildUid((int)($event['id'] ?? 0), (string)($event['relation_type'] ?? 'event')),
'DTSTAMP:' . gmdate('Ymd\THis\Z'),
'SUMMARY:' . $this->escapeText((string)($event['title'] ?? 'Papa-Kind-Treff Event')),
];
$startAt = trim((string)($event['starts_at'] ?? ''));
$endAt = trim((string)($event['ends_at'] ?? ''));
$isAllDay = $this->isAllDayEvent($startAt, $endAt);
if ($isAllDay) {
$startDate = $this->toDateString($startAt);
if ($startDate !== null) {
$lines[] = 'DTSTART;VALUE=DATE:' . $startDate;
$lines[] = 'DTEND;VALUE=DATE:' . $this->incrementDateString($startDate);
}
} else {
$startDateTime = $this->toDateTimeString($startAt);
if ($startDateTime !== null) {
$lines[] = 'DTSTART;TZID=Europe/Berlin:' . $startDateTime;
}
$endDateTime = $this->toDateTimeString($endAt);
if ($endDateTime !== null) {
$lines[] = 'DTEND;TZID=Europe/Berlin:' . $endDateTime;
}
}
$description = $this->buildDescription($event);
if ($description !== '') {
$lines[] = 'DESCRIPTION:' . $this->escapeText($description);
}
$location = $this->buildLocation($event);
if ($location !== '') {
$lines[] = 'LOCATION:' . $this->escapeText($location);
}
if (($event['status'] ?? '') === 'cancelled') {
$lines[] = 'STATUS:CANCELLED';
} else {
$lines[] = 'STATUS:CONFIRMED';
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function buildDescription(array $event): string
{
$parts = [];
$description = trim((string)($event['description'] ?? ''));
if ($description !== '') {
$parts[] = $description;
}
$meta = [];
if (($event['relation_type'] ?? '') === 'owner') {
$meta[] = 'Typ: Eigenes Event';
} elseif (($event['relation_type'] ?? '') === 'participant') {
$meta[] = 'Typ: Teilnahme an fremdem Event';
}
if (!empty($event['host_name'])) {
$meta[] = 'Veranstalter: ' . trim((string)$event['host_name']);
}
if (!empty($event['participation_status'])) {
$meta[] = 'Teilnahmestatus: ' . trim((string)$event['participation_status']);
}
if (!empty($event['category_slug'])) {
$meta[] = 'Kategorie: ' . trim((string)$event['category_slug']);
}
$meta[] = !empty($event['allow_kids']) ? 'Mit Kindern: Ja' : 'Mit Kindern: Nein';
$meta[] = 'Sichtbarkeit: ' . (($event['visibility'] ?? 'public') === 'members' ? 'Nur Mitglieder' : 'Öffentlich');
if (($event['status'] ?? '') === 'cancelled') {
$meta[] = 'Status: Abgesagt';
}
if ($meta !== []) {
$parts[] = implode("\n", $meta);
}
return trim(implode("\n\n", $parts));
}
private function buildLocation(array $event): string
{
$parts = array_filter([
trim((string)($event['street'] ?? '')),
trim((string)implode(' ', array_filter([
(string)($event['zip'] ?? ''),
(string)($event['city'] ?? ''),
]))),
trim((string)($event['region'] ?? '')),
]);
return trim(implode(', ', $parts));
}
private function buildUid(int $eventId, string $relationType): string
{
$host = preg_replace('/[^a-z0-9.-]+/i', '-', (string)($_SERVER['HTTP_HOST'] ?? 'papa-kind-treff.local')) ?: 'papa-kind-treff.local';
return sprintf('event-%d-%s@%s', $eventId, $relationType, $host);
}
private function isAllDayEvent(string $startAt, string $endAt): bool
{
if ($startAt === '') {
return false;
}
$startTime = substr($startAt, 11, 8);
$endTime = $endAt !== '' ? substr($endAt, 11, 8) : '';
return $startTime === '' || $startTime === '00:00:00' || $startTime === '23:59:59'
? ($endAt === '' || $endTime === '00:00:00' || $endTime === '23:59:59')
: false;
}
private function toDateString(string $value): ?string
{
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->format('Ymd');
} catch (\Throwable) {
return null;
}
}
private function incrementDateString(string $dateString): string
{
$date = \DateTimeImmutable::createFromFormat('Ymd', $dateString);
if (!$date instanceof \DateTimeImmutable) {
return $dateString;
}
return $date->modify('+1 day')->format('Ymd');
}
private function toDateTimeString(string $value): ?string
{
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value, new \DateTimeZone('Europe/Berlin')))->format('Ymd\THis');
} catch (\Throwable) {
return null;
}
}
private function escapeText(string $value): string
{
$value = str_replace(["\r\n", "\r"], "\n", trim($value));
$value = str_replace('\\', '\\\\', $value);
$value = str_replace(';', '\;', $value);
$value = str_replace(',', '\,', $value);
return str_replace("\n", '\n', $value);
}
private function foldLines(array $lines): string
{
$output = [];
foreach ($lines as $line) {
$line = (string)$line;
while (strlen($line) > 75) {
$output[] = substr($line, 0, 75);
$line = ' ' . substr($line, 75);
}
$output[] = $line;
}
return implode("\r\n", $output) . "\r\n";
}
private function pdo(): \PDO
{
$pdo = $this->app->pdo();
if (!$pdo instanceof \PDO) {
throw new \RuntimeException('Datenbankverbindung nicht verfügbar.');
}
return $pdo;
}
private function crypto(): Crypto
{
return new Crypto($this->app->config());
}
}