This commit is contained in:
@@ -14,6 +14,8 @@ Aktueller Projektstand
|
|||||||
- Der Generator besitzt einen Button `Random Profilbild`, der Seed und Varianten des aktiven Styles zufällig neu setzt.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Ä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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
- Das Profil-Menü enthält aktuell `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`, `Abmelden`.
|
||||||
- Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut.
|
- Der Mitgliederbereich selbst ist als Layout mit linker Bereichsnavigation aufgebaut.
|
||||||
- Kinder koennen im Mitgliederbereich angelegt, nachtraeglich bearbeitet und wieder geloescht werden.
|
- 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.
|
- 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.
|
- 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.
|
- Beim Oeffnen des Avatar-Generators wird nur der aktive Style-/Komponenten-Tab geladen; weitere Varianten werden erst beim Wechsel nachgeladen.
|
||||||
|
|||||||
@@ -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`
|
- eingeloggte Nutzer sehen rechts ein Profil-Menü mit Direktlinks zu `Profil`, `Kinder`, `Termine`, `Community`, `Einstellungen`
|
||||||
- der Mitgliederbereich ist als Seitenlayout mit linker Bereichsnavigation aufgebaut
|
- der Mitgliederbereich ist als Seitenlayout mit linker Bereichsnavigation aufgebaut
|
||||||
- im Bereich `Kinder` koennen vorhandene Eintraege jetzt auch nachtraeglich bearbeitet und geloescht werden
|
- 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
|
- Debug-Floating-Button nur für `site_admin` im Debug-Modus
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|||||||
@@ -583,6 +583,7 @@ $sectionLinks = [
|
|||||||
<div class="stack gap-6">
|
<div class="stack gap-6">
|
||||||
<label class="label" for="cAge">Alter (Jahre)</label>
|
<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) ?>">
|
<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>
|
</div>
|
||||||
<div class="stack gap-6">
|
<div class="stack gap-6">
|
||||||
@@ -880,6 +881,47 @@ $sectionLinks = [
|
|||||||
})();
|
})();
|
||||||
<?php endif; ?>
|
<?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(){
|
(function(){
|
||||||
const statusText = document.getElementById('locationBrowserStatusText');
|
const statusText = document.getElementById('locationBrowserStatusText');
|
||||||
const hintText = document.getElementById('locationBrowserStatusHint');
|
const hintText = document.getElementById('locationBrowserStatusHint');
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ if ($isLoggedIn) {
|
|||||||
static fn(string $column): string => $column,
|
static fn(string $column): string => $column,
|
||||||
\App\Avatar\AvatarManager::allProfileColumns()
|
\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']]);
|
$stmt->execute(['id' => (int)$_SESSION['user_id']]);
|
||||||
$profileRow = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
$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';
|
$communityCfg = dirname(__DIR__, 2) . '/config/community.php';
|
||||||
if (file_exists($communityCfg)) {
|
if (file_exists($communityCfg)) {
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ $clientCookie = $config->cookiePrefix() . 'client';
|
|||||||
<section>
|
<section>
|
||||||
<h2>5. Verschlüsselte und sensible Angaben</h2>
|
<h2>5. Verschlüsselte und sensible Angaben</h2>
|
||||||
<p>
|
<p>
|
||||||
Bestimmte Angaben, insbesondere Telefoninformationen und einzelne Kinderdaten, werden nicht nur organisatorisch,
|
Bestimmte Angaben, insbesondere sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen,
|
||||||
sondern zusätzlich verschlüsselt verarbeitet. Das dient dem Schutz besonders sensibler Angaben innerhalb der Plattform.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ CREATE TABLE users (
|
|||||||
CREATE TABLE user_profiles (
|
CREATE TABLE user_profiles (
|
||||||
user_id BIGINT UNSIGNED PRIMARY KEY,
|
user_id BIGINT UNSIGNED PRIMARY KEY,
|
||||||
display_name VARCHAR(120) NOT NULL,
|
display_name VARCHAR(120) NOT NULL,
|
||||||
first_name VARCHAR(120) NULL,
|
first_name VARBINARY(512) NULL,
|
||||||
last_name VARCHAR(120) NULL,
|
last_name VARBINARY(512) NULL,
|
||||||
share_level ENUM('basic','papa','papa_contact') NOT NULL DEFAULT 'basic',
|
share_level ENUM('basic','papa','papa_contact') NOT NULL DEFAULT 'basic',
|
||||||
children_visibility ENUM('hidden','age_only','details') NOT NULL DEFAULT 'hidden',
|
children_visibility ENUM('hidden','age_only','details') NOT NULL DEFAULT 'hidden',
|
||||||
zip CHAR(5) NULL,
|
zip CHAR(5) NULL,
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ use App\Avatar\Lorelei;
|
|||||||
|
|
||||||
final class AccountPages
|
final class AccountPages
|
||||||
{
|
{
|
||||||
|
private const ENCRYPTED_PROFILE_FIELDS = [
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'contact_phone',
|
||||||
|
'profession',
|
||||||
|
'languages',
|
||||||
|
'about',
|
||||||
|
];
|
||||||
|
|
||||||
public static function register(App $app): array
|
public static function register(App $app): array
|
||||||
{
|
{
|
||||||
$flash = $app->flash()->get();
|
$flash = $app->flash()->get();
|
||||||
@@ -169,11 +178,17 @@ final class AccountPages
|
|||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
try {
|
try {
|
||||||
if ($action === 'profile') {
|
if ($action === 'profile') {
|
||||||
|
$crypto = self::requireCrypto($crypto, 'Profil');
|
||||||
$languages = $_POST['languages'] ?? '';
|
$languages = $_POST['languages'] ?? '';
|
||||||
if (is_array($languages)) {
|
if (is_array($languages)) {
|
||||||
$languages = implode(', ', array_map('trim', $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');
|
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
|
||||||
if ($profileSettings) {
|
if ($profileSettings) {
|
||||||
$profileSettings->ensureSchema();
|
$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 = $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([
|
$stmt?->execute([
|
||||||
'name' => trim((string)$_POST['display_name']),
|
'name' => trim((string)$_POST['display_name']),
|
||||||
'fname' => trim((string)$_POST['first_name']),
|
'fname' => $firstNameEnc,
|
||||||
'lname' => trim((string)$_POST['last_name']),
|
'lname' => $lastNameEnc,
|
||||||
'zip' => trim((string)$_POST['zip']),
|
'zip' => trim((string)$_POST['zip']),
|
||||||
'city' => trim((string)$_POST['city']),
|
'city' => trim((string)$_POST['city']),
|
||||||
'prof' => trim((string)$_POST['profession']),
|
'prof' => $professionEnc,
|
||||||
'langs' => trim((string)$languages),
|
'langs' => $languagesEnc,
|
||||||
'about' => trim((string)$_POST['about']),
|
'about' => $aboutEnc,
|
||||||
'phone' => $phoneEnc,
|
'phone' => $phoneEnc,
|
||||||
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
|
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
|
||||||
'id' => $userId,
|
'id' => $userId,
|
||||||
@@ -208,12 +223,13 @@ final class AccountPages
|
|||||||
}
|
}
|
||||||
$info = 'Einstellungen gespeichert.';
|
$info = 'Einstellungen gespeichert.';
|
||||||
} elseif ($action === 'child_add' || $action === 'child_update') {
|
} elseif ($action === 'child_add' || $action === 'child_update') {
|
||||||
|
$crypto = self::requireCrypto($crypto, 'Kinder');
|
||||||
$childId = (int)($_POST['child_id'] ?? 0);
|
$childId = (int)($_POST['child_id'] ?? 0);
|
||||||
$firstName = trim((string)($_POST['first_name'] ?? ''));
|
$firstName = trim((string)($_POST['first_name'] ?? ''));
|
||||||
$gender = (string)($_POST['gender'] ?? 'unknown');
|
$gender = (string)($_POST['gender'] ?? 'unknown');
|
||||||
$birthdate = trim((string)($_POST['birthdate'] ?? ''));
|
$birthdate = trim((string)($_POST['birthdate'] ?? ''));
|
||||||
$ageYearsRaw = trim((string)($_POST['age_years'] ?? ''));
|
$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'] ?? ''));
|
$note = trim((string)($_POST['note'] ?? ''));
|
||||||
|
|
||||||
if ($firstName === '') {
|
if ($firstName === '') {
|
||||||
@@ -222,9 +238,16 @@ final class AccountPages
|
|||||||
if (!in_array($gender, ['male', 'female', 'diverse', 'unknown'], true)) {
|
if (!in_array($gender, ['male', 'female', 'diverse', 'unknown'], true)) {
|
||||||
$gender = 'unknown';
|
$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;
|
$firstNameEnc = $crypto->encrypt($firstName);
|
||||||
$noteEnc = $crypto ? $crypto->encrypt($note) : $note;
|
$noteEnc = $note !== '' ? $crypto->encrypt($note) : '';
|
||||||
|
|
||||||
if ($action === 'child_add') {
|
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())');
|
$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);
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
if ($row) {
|
if ($row) {
|
||||||
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
|
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
|
||||||
if ($crypto && !empty($profile['contact_phone'])) {
|
foreach (self::ENCRYPTED_PROFILE_FIELDS as $field) {
|
||||||
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
|
$profile[$field] = self::decodeStoredProfileField(
|
||||||
|
$profile[$field] ?? '',
|
||||||
|
$crypto,
|
||||||
|
$pdo,
|
||||||
|
'user_profiles',
|
||||||
|
$field,
|
||||||
|
'user_id',
|
||||||
|
$userId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$profile = AvatarManager::normalizeProfile($profile, $userId);
|
$profile = AvatarManager::normalizeProfile($profile, $userId);
|
||||||
@@ -422,9 +453,38 @@ final class AccountPages
|
|||||||
$stmt?->execute(['id' => $userId]);
|
$stmt?->execute(['id' => $userId]);
|
||||||
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
foreach ($childrenRaw as $c) {
|
foreach ($childrenRaw as $c) {
|
||||||
if ($crypto) {
|
$c['first_name'] = self::decodeStoredProfileField(
|
||||||
$c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: '';
|
$c['first_name'] ?? '',
|
||||||
$c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: '';
|
$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;
|
$children[] = $c;
|
||||||
if ($editChildId > 0 && (int)$c['id'] === $editChildId) {
|
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
|
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
|
||||||
{
|
{
|
||||||
$parts = array_filter([
|
$parts = array_filter([
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App;
|
|||||||
|
|
||||||
final class Crypto
|
final class Crypto
|
||||||
{
|
{
|
||||||
|
private const ENCRYPTED_PREFIX = 'enc:';
|
||||||
private string $key;
|
private string $key;
|
||||||
|
|
||||||
public function __construct(Config $config)
|
public function __construct(Config $config)
|
||||||
@@ -44,7 +45,7 @@ final class Crypto
|
|||||||
}
|
}
|
||||||
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
|
$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
|
public function decrypt(?string $blob): string
|
||||||
@@ -52,7 +53,8 @@ final class Crypto
|
|||||||
if ($blob === null || $blob === '') {
|
if ($blob === null || $blob === '') {
|
||||||
return '';
|
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) {
|
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -65,4 +67,19 @@ final class Crypto
|
|||||||
return '';
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ final class ProfileSettings
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->ensureSensitiveProfileColumns();
|
||||||
|
|
||||||
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
|
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
|
||||||
$this->pdo->exec(
|
$this->pdo->exec(
|
||||||
"ALTER TABLE user_profiles
|
"ALTER TABLE user_profiles
|
||||||
@@ -54,6 +56,32 @@ final class ProfileSettings
|
|||||||
$this->schemaEnsured = true;
|
$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
|
public function getLocationTrackingPreference(int $userId): string
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
@@ -146,4 +174,30 @@ final class ProfileSettings
|
|||||||
|
|
||||||
return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user