This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
public/page/calendar/export.php
Normal file
26
public/page/calendar/export.php
Normal 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;
|
||||||
35
public/page/calendar/feed.php
Normal file
35
public/page/calendar/feed.php
Normal 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;
|
||||||
@@ -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 & Cookies</h1>
|
<h1>Datenschutz & 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.
|
||||||
|
|||||||
11
schema.sql
11
schema.sql
@@ -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,
|
||||||
|
|||||||
@@ -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
366
src/App/CalendarSync.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user