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

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