adasd
All checks were successful
Deploy / deploy (push) Successful in 58s

This commit is contained in:
2026-07-30 00:04:51 +02:00
parent 76591569b6
commit f6cb211859
10 changed files with 261 additions and 22 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -583,6 +583,7 @@ $sectionLinks = [
<div class="stack gap-6">
<label class="label" for="cAge">Alter (Jahre)</label>
<input id="cAge" name="age_years" class="input" type="number" min="0" max="18" value="<?= htmlspecialchars((string)($editChild['age_years'] ?? ''), ENT_QUOTES) ?>">
<p class="muted small" style="margin:0;">Mit Geburtsdatum wird das Alter automatisch berechnet und jaehrlich aktualisiert. Ohne Geburtsdatum gilt der manuelle Wert.</p>
</div>
</div>
<div class="stack gap-6">
@@ -880,6 +881,47 @@ $sectionLinks = [
})();
<?php endif; ?>
(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');

View File

@@ -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)) {

View File

@@ -74,8 +74,9 @@ $clientCookie = $config->cookiePrefix() . 'client';
<section>
<h2>5. Verschlüsselte und sensible Angaben</h2>
<p>
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.
</p>
</section>

View File

@@ -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,

View File

@@ -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([

View File

@@ -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;
}
}

View File

@@ -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;
}
}