From f6cb211859bbef218d113a18c0c80175215316a8 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Thu, 30 Jul 2026 00:04:51 +0200 Subject: [PATCH] adasd --- .projektstructure.txt | 2 + PROJECT_CONTEXT.md | 2 + README.md | 2 + partials/landing/account/dashboard.php | 42 +++++++ partials/structure/nav.php | 4 +- public/page/datenschutz.php | 5 +- schema.sql | 4 +- src/App/AccountPages.php | 147 ++++++++++++++++++++++--- src/App/Crypto.php | 21 +++- src/App/ProfileSettings.php | 54 +++++++++ 10 files changed, 261 insertions(+), 22 deletions(-) diff --git a/.projektstructure.txt b/.projektstructure.txt index 7eae7a3..9dadc1c 100755 --- a/.projektstructure.txt +++ b/.projektstructure.txt @@ -14,6 +14,8 @@ Aktueller Projektstand - Der Generator besitzt einen Button `Random Profilbild`, der Seed und Varianten des aktiven Styles zufällig neu setzt. - Beim Oeffnen des Modals werden nur die Bilder des aktiven Styles und Tabs geladen; weitere Tabs laden ihre Varianten erst beim Wechsel nach. - Kinder koennen im Mitgliederbereich zusaetzlich nachtraeglich bearbeitet und geloescht werden. +- Wenn bei einem Kind ein Geburtsdatum hinterlegt ist, wird das Alter automatisch daraus berechnet und spaeter bei Bedarf jaehrlich aktualisiert; ohne Geburtsdatum bleibt das manuell gepflegte Alter massgeblich. +- Profilbezogene sensible Angaben wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; vorhandene Klartextwerte werden bei der Nutzung schrittweise nachmigriert. - Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt. - Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden. diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 1af48c9..15874ef 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -34,6 +34,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`. - Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut. - Kinder koennen im Mitgliederbereich angelegt, nachtraeglich bearbeitet und wieder geloescht werden. +- Wenn ein Geburtsdatum gesetzt ist, wird das Alter im Mitgliederbereich automatisch berechnet und spaeter bei Bedarf anhand des Datums aktualisiert; ohne Geburtsdatum gilt der manuelle Alterswert. +- Sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden bei Nutzung schrittweise in den verschluesselten Zustand ueberfuehrt. - Im Profil kann der Nutzer aktuell Komponenten wie Augen, Augenbrauen, Mund, Brille, Haare, Bart und Ohrringe direkt per Vorschaubild auswählen. - Der Button `Random Profilbild` setzt Seed und Varianten des aktiven Styles zufaellig neu und aktualisiert die Vorschau sofort. - Beim Oeffnen des Avatar-Generators wird nur der aktive Style-/Komponenten-Tab geladen; weitere Varianten werden erst beim Wechsel nachgeladen. diff --git a/README.md b/README.md index c56ab21..fb552ce 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen` - der Mitgliederbereich ist als Seitenlayout mit linker Bereichsnavigation aufgebaut - im Bereich `Kinder` koennen vorhandene Eintraege jetzt auch nachtraeglich bearbeitet und geloescht werden +- bei Kindern mit Geburtsdatum wird das Alter automatisch berechnet und bei Aufruf des Mitgliederbereichs bei Bedarf jaehrlich nachgezogen; ohne Geburtsdatum gilt das manuell gepflegte Alter +- sensible Profildaten werden im Mitgliederbereich jetzt konsequent app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden beim Lesen schrittweise in den verschluesselten Zustand ueberfuehrt - Debug-Floating-Button nur für `site_admin` im Debug-Modus ## Technik diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index 8347c02..f001c9f 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -583,6 +583,7 @@ $sectionLinks = [
+

Mit Geburtsdatum wird das Alter automatisch berechnet und jaehrlich aktualisiert. Ohne Geburtsdatum gilt der manuelle Wert.

@@ -880,6 +881,47 @@ $sectionLinks = [ })(); + (function(){ + const birthInput = document.getElementById('cBirth'); + const ageInput = document.getElementById('cAge'); + + if (!birthInput || !ageInput) return; + + const calculateAge = (value) => { + if (!value) return null; + const birthDate = new Date(`${value}T00:00:00`); + if (Number.isNaN(birthDate.getTime())) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (birthDate > today) return null; + + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + const dayDiff = today.getDate() - birthDate.getDate(); + if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) { + age -= 1; + } + return Math.max(0, age); + }; + + const syncAgeField = () => { + const calculatedAge = calculateAge(birthInput.value); + if (calculatedAge === null) { + ageInput.readOnly = false; + ageInput.removeAttribute('aria-readonly'); + return; + } + + ageInput.value = String(calculatedAge); + ageInput.readOnly = true; + ageInput.setAttribute('aria-readonly', 'true'); + }; + + birthInput.addEventListener('input', syncAgeField); + syncAgeField(); + })(); + (function(){ const statusText = document.getElementById('locationBrowserStatusText'); const hintText = document.getElementById('locationBrowserStatusHint'); diff --git a/partials/structure/nav.php b/partials/structure/nav.php index 1558278..a28b821 100755 --- a/partials/structure/nav.php +++ b/partials/structure/nav.php @@ -15,10 +15,10 @@ if ($isLoggedIn) { static fn(string $column): string => $column, \App\Avatar\AvatarManager::allProfileColumns() )); - $stmt = $pdo->prepare("SELECT user_id, display_name, first_name, $avatarColumns FROM user_profiles WHERE user_id = :id LIMIT 1"); + $stmt = $pdo->prepare("SELECT user_id, display_name, $avatarColumns FROM user_profiles WHERE user_id = :id LIMIT 1"); $stmt->execute(['id' => (int)$_SESSION['user_id']]); $profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; - $displayName = trim((string)($profileRow['display_name'] ?? '')) ?: trim((string)($profileRow['first_name'] ?? '')) ?: 'Profil'; + $displayName = trim((string)($profileRow['display_name'] ?? '')) ?: 'Profil'; $communityCfg = dirname(__DIR__, 2) . '/config/community.php'; if (file_exists($communityCfg)) { diff --git a/public/page/datenschutz.php b/public/page/datenschutz.php index 7de8d70..becb739 100644 --- a/public/page/datenschutz.php +++ b/public/page/datenschutz.php @@ -74,8 +74,9 @@ $clientCookie = $config->cookiePrefix() . 'client';

5. Verschlüsselte und sensible Angaben

- Bestimmte Angaben, insbesondere Telefoninformationen und einzelne Kinderdaten, werden nicht nur organisatorisch, - sondern zusätzlich verschlüsselt verarbeitet. Das dient dem Schutz besonders sensibler Angaben innerhalb der Plattform. + Bestimmte Angaben, insbesondere sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen, + Kurzvorstellung sowie einzelne Kinderdaten, werden nicht nur organisatorisch, sondern zusätzlich verschlüsselt verarbeitet. + Das dient dem Schutz besonders sensibler Angaben innerhalb der Plattform.

diff --git a/schema.sql b/schema.sql index 9ef1cf4..feb4767 100755 --- a/schema.sql +++ b/schema.sql @@ -20,8 +20,8 @@ CREATE TABLE users ( CREATE TABLE user_profiles ( user_id BIGINT UNSIGNED PRIMARY KEY, display_name VARCHAR(120) NOT NULL, - first_name VARCHAR(120) NULL, - last_name VARCHAR(120) NULL, + first_name VARBINARY(512) NULL, + last_name VARBINARY(512) NULL, share_level ENUM('basic','papa','papa_contact') NOT NULL DEFAULT 'basic', children_visibility ENUM('hidden','age_only','details') NOT NULL DEFAULT 'hidden', zip CHAR(5) NULL, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index b5d6b79..56c32e6 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -8,6 +8,15 @@ use App\Avatar\Lorelei; final class AccountPages { + private const ENCRYPTED_PROFILE_FIELDS = [ + 'first_name', + 'last_name', + 'contact_phone', + 'profession', + 'languages', + 'about', + ]; + public static function register(App $app): array { $flash = $app->flash()->get(); @@ -169,11 +178,17 @@ final class AccountPages $action = $_POST['action'] ?? ''; try { if ($action === 'profile') { + $crypto = self::requireCrypto($crypto, 'Profil'); $languages = $_POST['languages'] ?? ''; if (is_array($languages)) { $languages = implode(', ', array_map('trim', $languages)); } - $phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']); + $firstNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['first_name'])); + $lastNameEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['last_name'])); + $phoneEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['contact_phone'])); + $professionEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['profession'])); + $languagesEnc = self::encryptOptionalProfileField($crypto, trim((string)$languages)); + $aboutEnc = self::encryptOptionalProfileField($crypto, trim((string)$_POST['about'])); $locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt'); if ($profileSettings) { $profileSettings->ensureSchema(); @@ -181,13 +196,13 @@ final class AccountPages $stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id'); $stmt?->execute([ 'name' => trim((string)$_POST['display_name']), - 'fname' => trim((string)$_POST['first_name']), - 'lname' => trim((string)$_POST['last_name']), + 'fname' => $firstNameEnc, + 'lname' => $lastNameEnc, 'zip' => trim((string)$_POST['zip']), 'city' => trim((string)$_POST['city']), - 'prof' => trim((string)$_POST['profession']), - 'langs' => trim((string)$languages), - 'about' => trim((string)$_POST['about']), + 'prof' => $professionEnc, + 'langs' => $languagesEnc, + 'about' => $aboutEnc, 'phone' => $phoneEnc, 'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt', 'id' => $userId, @@ -208,12 +223,13 @@ final class AccountPages } $info = 'Einstellungen gespeichert.'; } elseif ($action === 'child_add' || $action === 'child_update') { + $crypto = self::requireCrypto($crypto, 'Kinder'); $childId = (int)($_POST['child_id'] ?? 0); $firstName = trim((string)($_POST['first_name'] ?? '')); $gender = (string)($_POST['gender'] ?? 'unknown'); $birthdate = trim((string)($_POST['birthdate'] ?? '')); $ageYearsRaw = trim((string)($_POST['age_years'] ?? '')); - $ageYears = $ageYearsRaw !== '' ? max(0, min(18, (int)$ageYearsRaw)) : null; + $ageYears = $ageYearsRaw !== '' ? max(0, (int)$ageYearsRaw) : null; $note = trim((string)($_POST['note'] ?? '')); if ($firstName === '') { @@ -222,9 +238,16 @@ final class AccountPages if (!in_array($gender, ['male', 'female', 'diverse', 'unknown'], true)) { $gender = 'unknown'; } + if ($birthdate !== '') { + $calculatedAge = self::calculateAgeFromBirthdate($birthdate); + if ($calculatedAge === null) { + throw new \RuntimeException('Bitte gib ein gueltiges Geburtsdatum an.'); + } + $ageYears = $calculatedAge; + } - $firstNameEnc = $crypto ? $crypto->encrypt($firstName) : $firstName; - $noteEnc = $crypto ? $crypto->encrypt($note) : $note; + $firstNameEnc = $crypto->encrypt($firstName); + $noteEnc = $note !== '' ? $crypto->encrypt($note) : ''; if ($action === 'child_add') { $stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())'); @@ -409,8 +432,16 @@ final class AccountPages $row = $stmt?->fetch(\PDO::FETCH_ASSOC); if ($row) { $profile = array_merge($profile, array_filter($row, fn($v) => $v !== null)); - if ($crypto && !empty($profile['contact_phone'])) { - $profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: ''; + foreach (self::ENCRYPTED_PROFILE_FIELDS as $field) { + $profile[$field] = self::decodeStoredProfileField( + $profile[$field] ?? '', + $crypto, + $pdo, + 'user_profiles', + $field, + 'user_id', + $userId + ); } } $profile = AvatarManager::normalizeProfile($profile, $userId); @@ -422,9 +453,38 @@ final class AccountPages $stmt?->execute(['id' => $userId]); $childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; foreach ($childrenRaw as $c) { - if ($crypto) { - $c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: ''; - $c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: ''; + $c['first_name'] = self::decodeStoredProfileField( + $c['first_name'] ?? '', + $crypto, + $pdo, + 'children', + 'encrypted_first_name', + 'id', + (int)$c['id'] + ); + $c['note'] = self::decodeStoredProfileField( + $c['note'] ?? '', + $crypto, + $pdo, + 'children', + 'note', + 'id', + (int)$c['id'] + ); + if (!empty($c['birthdate'])) { + $calculatedAge = self::calculateAgeFromBirthdate((string)$c['birthdate']); + if ($calculatedAge !== null) { + $storedAge = isset($c['age_years']) && $c['age_years'] !== null ? (int)$c['age_years'] : null; + $c['age_years'] = $calculatedAge; + if ($storedAge !== $calculatedAge) { + $updateChildAgeStmt = $updateChildAgeStmt ?? $pdo?->prepare('UPDATE children SET age_years = :age, updated_at = NOW() WHERE id = :id AND user_id = :uid'); + $updateChildAgeStmt?->execute([ + 'age' => $calculatedAge, + 'id' => (int)$c['id'], + 'uid' => $userId, + ]); + } + } } $children[] = $c; if ($editChildId > 0 && (int)$c['id'] === $editChildId) { @@ -499,6 +559,65 @@ final class AccountPages ); } + private static function calculateAgeFromBirthdate(string $birthdate): ?int + { + try { + $birthDateValue = new \DateTimeImmutable($birthdate); + } catch (\Throwable) { + return null; + } + + $today = new \DateTimeImmutable('today'); + if ($birthDateValue > $today) { + return null; + } + + return $birthDateValue->diff($today)->y; + } + + private static function requireCrypto(?Crypto $crypto, string $context): Crypto + { + if ($crypto instanceof Crypto) { + return $crypto; + } + + throw new \RuntimeException($context . '-Daten koennen derzeit nicht sicher verarbeitet werden. Bitte spaeter erneut versuchen.'); + } + + private static function encryptOptionalProfileField(Crypto $crypto, string $value): string + { + return $value !== '' ? $crypto->encrypt($value) : ''; + } + + private static function decodeStoredProfileField( + mixed $value, + ?Crypto $crypto, + ?\PDO $pdo, + string $table, + string $column, + string $idColumn, + int $id + ): string { + $rawValue = (string)$value; + if ($rawValue === '') { + return ''; + } + + if (Crypto::looksEncrypted($rawValue)) { + return $crypto ? ($crypto->decrypt($rawValue) ?: '') : ''; + } + + if ($crypto && $pdo && $id > 0) { + $stmt = $pdo->prepare(sprintf('UPDATE %s SET %s = :value, updated_at = NOW() WHERE %s = :id', $table, $column, $idColumn)); + $stmt->execute([ + 'value' => $crypto->encrypt($rawValue), + 'id' => $id, + ]); + } + + return $rawValue; + } + private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array { $parts = array_filter([ diff --git a/src/App/Crypto.php b/src/App/Crypto.php index c5d9dfa..cd6cf37 100755 --- a/src/App/Crypto.php +++ b/src/App/Crypto.php @@ -5,6 +5,7 @@ namespace App; final class Crypto { + private const ENCRYPTED_PREFIX = 'enc:'; private string $key; public function __construct(Config $config) @@ -44,7 +45,7 @@ final class Crypto } $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key); - return base64_encode($nonce . $cipher); + return self::ENCRYPTED_PREFIX . base64_encode($nonce . $cipher); } public function decrypt(?string $blob): string @@ -52,7 +53,8 @@ final class Crypto if ($blob === null || $blob === '') { return ''; } - $raw = base64_decode($blob, true); + $payload = str_starts_with($blob, self::ENCRYPTED_PREFIX) ? substr($blob, strlen(self::ENCRYPTED_PREFIX)) : $blob; + $raw = base64_decode($payload, true); if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) { return ''; } @@ -65,4 +67,19 @@ final class Crypto return ''; } } + + public static function looksEncrypted(?string $blob): bool + { + if ($blob === null || $blob === '') { + return false; + } + + $payload = str_starts_with($blob, self::ENCRYPTED_PREFIX) ? substr($blob, strlen(self::ENCRYPTED_PREFIX)) : $blob; + $raw = base64_decode($payload, true); + if ($raw === false) { + return false; + } + + return strlen($raw) > SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; + } } diff --git a/src/App/ProfileSettings.php b/src/App/ProfileSettings.php index 0904cac..cfb78e5 100644 --- a/src/App/ProfileSettings.php +++ b/src/App/ProfileSettings.php @@ -21,6 +21,8 @@ final class ProfileSettings return; } + $this->ensureSensitiveProfileColumns(); + if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) { $this->pdo->exec( "ALTER TABLE user_profiles @@ -54,6 +56,32 @@ final class ProfileSettings $this->schemaEnsured = true; } + private function ensureSensitiveProfileColumns(): void + { + $columnDefinitions = [ + 'first_name' => 'VARBINARY(512) NULL', + 'last_name' => 'VARBINARY(512) NULL', + 'contact_phone' => 'VARBINARY(512) NULL', + 'contact_email' => 'VARBINARY(512) NULL', + 'profession' => 'VARBINARY(512) NULL', + 'languages' => 'VARBINARY(1024) NULL', + 'about' => 'VARBINARY(2048) NULL', + ]; + + foreach ($columnDefinitions as $column => $definition) { + if (!$this->hasColumn('user_profiles', $column)) { + continue; + } + + $dataType = $this->getColumnDataType('user_profiles', $column); + if ($dataType !== 'varbinary') { + $this->pdo->exec(sprintf('ALTER TABLE user_profiles MODIFY COLUMN %s %s', $column, $definition)); + $this->columnCache['user_profiles.' . $column] = true; + $this->columnTypeCache['user_profiles.' . $column] = 'varbinary'; + } + } + } + public function getLocationTrackingPreference(int $userId): string { if ($userId <= 0) { @@ -146,4 +174,30 @@ final class ProfileSettings return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0); } + + private array $columnTypeCache = []; + + private function getColumnDataType(string $table, string $column): ?string + { + $cacheKey = $table . '.' . $column; + if (array_key_exists($cacheKey, $this->columnTypeCache)) { + return $this->columnTypeCache[$cacheKey]; + } + + $stmt = $this->pdo->prepare(" + SELECT DATA_TYPE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :tableName + AND COLUMN_NAME = :columnName + LIMIT 1 + "); + $stmt->execute([ + 'tableName' => $table, + 'columnName' => $column, + ]); + + $value = $stmt->fetchColumn(); + return $this->columnTypeCache[$cacheKey] = $value !== false ? strtolower((string)$value) : null; + } }