users = new UserRepository(); $this->settings = new SettingsRepository(); $this->entries = new EntryRepository(); $this->throttle = new LoginThrottle(); $this->scoring = new ScoringService(); $this->auth = new Auth($this->users); } public function run(): void { $this->sendSecurityHeaders(); $path = request_path(); $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $hasUsers = $this->users->hasAnyUsers(); $isAuthenticated = $this->auth->check(); // A failed setup must never leave the app in a half-authenticated redirect loop. if (!$hasUsers && $isAuthenticated) { $this->auth->logout(); $isAuthenticated = false; } if (!$hasUsers) { if ($path === '/login') { $path = '/setup'; } elseif ($path !== '/setup') { redirect('/setup'); } } elseif (!$isAuthenticated) { if ($path === '/setup') { $path = '/login'; } elseif ($path !== '/login') { redirect('/login'); } } if ($isAuthenticated && in_array($path, ['/login', '/setup'], true)) { redirect('/'); } switch ($path) { case '/setup': $method === 'POST' ? $this->handleSetup() : $this->showSetup(); return; case '/login': $method === 'POST' ? $this->handleLogin() : $this->showLogin(); return; case '/logout': if ($method !== 'POST') { http_response_code(405); exit('Method Not Allowed'); } $this->enforceCsrf(); $this->auth->logout(); flash('success', 'Du wurdest abgemeldet.'); redirect('/login'); case '/': $this->showDashboard(); return; case '/track': $method === 'POST' ? $this->handleTrack() : $this->showTrack(); return; case '/archive': $this->showArchive(); return; case '/options': $method === 'POST' ? $this->handleOptions() : $this->showOptions(); return; default: http_response_code(404); View::render('not-found', [ 'pageTitle' => 'Nicht gefunden', 'page' => 'not-found', 'authUser' => $this->auth->user(), ]); } } private function showSetup(): void { View::render('setup', [ 'pageTitle' => 'Setup', 'page' => 'setup', 'authUser' => null, ]); } private function handleSetup(): void { $this->enforceCsrf(); if ($this->users->hasAnyUsers()) { redirect('/login'); } $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); if (!$this->isValidUsername($username)) { flash('error', 'Bitte nutze einen Benutzernamen mit 3 bis 32 Zeichen aus Buchstaben, Zahlen, Punkt, Minus oder Unterstrich.'); redirect('/setup'); } if (!$this->isStrongPassword($password)) { flash('error', 'Das Passwort sollte mindestens 10 Zeichen lang sein.'); redirect('/setup'); } if ($password !== $passwordConfirm) { flash('error', 'Die Passwörter stimmen nicht überein.'); redirect('/setup'); } try { $user = $this->users->create($username, $password, true); $this->auth->login($user); flash('success', 'Der erste Account wurde erstellt. Du kannst direkt loslegen.'); redirect('/'); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); redirect('/setup'); } } private function showLogin(): void { View::render('login', [ 'pageTitle' => 'Login', 'page' => 'login', 'authUser' => null, ]); } private function handleLogin(): void { $this->enforceCsrf(); $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $throttleKey = $this->throttleKey($username); if ($this->throttle->tooManyAttempts($throttleKey)) { $seconds = $this->throttle->availableInSeconds($throttleKey); flash('error', 'Zu viele fehlgeschlagene Login-Versuche. Bitte warte ' . max(1, $seconds) . ' Sekunden.'); redirect('/login'); } $remember = isset($_POST['remember_me']) && $_POST['remember_me'] === '1'; if (!$this->auth->attempt($username, $password, $remember)) { $this->throttle->hit($throttleKey); flash('error', 'Login fehlgeschlagen. Bitte prüfe Benutzername und Passwort.'); redirect('/login'); } $this->throttle->clear($throttleKey); flash('success', 'Willkommen zurück.'); redirect('/'); } private function showDashboard(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entries = $this->entries->all($user['username']); $evaluatedEntries = $this->evaluateEntriesWithContext($entries, $settings); $summary = $this->buildDashboardSummary($evaluatedEntries); $chartData = $this->buildDashboardCharts($evaluatedEntries); View::render('dashboard', [ 'pageTitle' => 'Dashboard', 'page' => 'dashboard', 'authUser' => $user, 'summary' => $summary, 'entries' => array_reverse($evaluatedEntries), 'chartPayload' => encode_payload($chartData), ]); } private function showTrack(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $date = (string) ($_GET['date'] ?? today()); if (!$this->isValidDate($date)) { $date = today(); } $entry = $this->entries->find($user['username'], $date) ?? [ 'date' => $date, 'mood' => 6, 'energy' => 6, 'stress' => 4, 'sleep_hours' => 7, 'sleep_feeling' => 3, 'sport_minutes' => 0, 'sport_type' => '', 'sport_types' => [], 'walk_minutes' => 0, 'note' => '', ]; $entry = $this->scoring->normalize($entry); $previousEntry = $this->entries->find($user['username'], shift_date($date, -1)); $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); View::render('track', [ 'pageTitle' => 'Tag tracken', 'page' => 'track', 'authUser' => $user, 'entry' => $entry, 'evaluation' => $evaluation, 'settings' => $settings, 'sportTypes' => normalized_sport_types($settings), 'trackMood' => $evaluation['sentiment'], 'topbarDate' => $entry['date'], 'trackPayload' => encode_payload([ 'settings' => $settings, 'entry' => $entry, 'previousEntry' => $previousEntry !== null ? $this->scoring->normalize($previousEntry) : null, ]), ]); } private function handleTrack(): void { $this->enforceCsrf(); $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $entry = $this->scoring->normalize([ 'date' => $_POST['date'] ?? today(), 'mood' => $_POST['mood'] ?? 5, 'energy' => $_POST['energy'] ?? 5, 'stress' => $_POST['stress'] ?? 5, 'sleep_hours' => $_POST['sleep_hours'] ?? 0, 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_types' => $_POST['sport_types'] ?? [], 'walk_minutes' => $_POST['walk_minutes'] ?? 0, 'note' => $_POST['note'] ?? '', ]); if (!$this->isValidDate($entry['date'])) { flash('error', 'Bitte wähle ein gültiges Datum.'); redirect('/track'); } $previousEntry = $this->entries->find($user['username'], shift_date($entry['date'], -1)); $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $this->entries->save($user['username'], $entry['date'], $entry, $evaluation); flash('success', 'Der Tag wurde gespeichert.'); redirect('/track?date=' . rawurlencode($entry['date'])); } private function showArchive(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null; $entries = $this->entries->all($user['username']); $archive = array_reverse($this->evaluateEntriesWithContext($entries, $settings)); $selectedEntry = null; if ($selectedDate !== null) { foreach ($archive as $entry) { if ($entry['date'] === $selectedDate) { $selectedEntry = $entry; break; } } } View::render('archive', [ 'pageTitle' => 'Archiv', 'page' => 'archive', 'authUser' => $user, 'entries' => $archive, 'selectedEntry' => $selectedEntry, ]); } private function showOptions(): void { $user = $this->requireUser(); $settings = $this->hydrateSettings($this->settings->forUser($user['username'])); $sportTypePresets = array_values(array_filter( Defaults::settings()['sport_types'], static function (array $preset) use ($settings): bool { foreach (normalized_sport_types($settings) as $type) { if (($type['id'] ?? '') === ($preset['id'] ?? '')) { return false; } } return true; } )); View::render('options', [ 'pageTitle' => 'Optionen', 'page' => 'options', 'authUser' => $user, 'settings' => $settings, 'sportTypePresets' => $sportTypePresets, 'sportLocationOptions' => sport_location_options(), 'users' => $user['is_admin'] ? $this->users->all() : [], 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, 'energy' => 10, 'stress' => 1, 'sleep_hours' => 7, 'sleep_feeling' => 5, 'sport_minutes' => 999, 'sport_types' => array_map( static fn (array $type): string => (string) ($type['id'] ?? ''), normalized_sport_types($settings) ), 'walk_minutes' => 999, 'note' => 'x', ], $settings)['max_total'], ]); } private function handleOptions(): void { $this->enforceCsrf(); $user = $this->requireUser(); $form = (string) ($_POST['form_name'] ?? ''); if ($form === 'settings') { $settings = $this->sanitizeSettings($_POST['settings'] ?? []); $this->settings->saveForUser($user['username'], $settings); flash('success', 'Deine persönlichen Optionen wurden aktualisiert.'); redirect('/options'); } if ($form === 'password') { $current = (string) ($_POST['current_password'] ?? ''); $new = (string) ($_POST['new_password'] ?? ''); $confirm = (string) ($_POST['new_password_confirm'] ?? ''); if ($this->users->verify($user['username'], $current) === null) { flash('error', 'Das aktuelle Passwort stimmt nicht.'); redirect('/options'); } if (!$this->isStrongPassword($new)) { flash('error', 'Das neue Passwort sollte mindestens 10 Zeichen lang sein.'); redirect('/options'); } if ($new !== $confirm) { flash('error', 'Die neuen Passwörter stimmen nicht überein.'); redirect('/options'); } $this->users->changePassword($user['username'], $new); flash('success', 'Dein Passwort wurde aktualisiert.'); redirect('/options'); } if ($form === 'create_user' && ($user['is_admin'] ?? false)) { $username = trim((string) ($_POST['username'] ?? '')); $password = (string) ($_POST['password'] ?? ''); $isAdmin = isset($_POST['is_admin']) && $_POST['is_admin'] === '1'; if (!$this->isValidUsername($username)) { flash('error', 'Bitte nutze für neue Accounts einen sauberen Benutzernamen.'); redirect('/options'); } if (!$this->isStrongPassword($password)) { flash('error', 'Das Startpasswort sollte mindestens 10 Zeichen lang sein.'); redirect('/options'); } try { $this->users->create($username, $password, $isAdmin); flash('success', 'Der neue Account wurde angelegt.'); } catch (RuntimeException $exception) { flash('error', $exception->getMessage()); } redirect('/options'); } redirect('/options'); } private function buildDashboardSummary(array $entries): array { $count = count($entries); $todayEntry = null; foreach ($entries as $entry) { if ($entry['date'] === today()) { $todayEntry = $entry; } } $avgScore = $count > 0 ? round(array_sum(array_map(static fn (array $entry): float => (float) $entry['evaluation']['total'], $entries)) / $count, 1) : 0.0; $avgMood = $count > 0 ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['mood'], $entries)) / $count, 1) : 0.0; $avgStress = $count > 0 ? round(array_sum(array_map(static fn (array $entry): int => (int) $entry['stress'], $entries)) / $count, 1) : 0.0; return [ 'tracked_days' => $count, 'average_score' => $avgScore, 'average_mood' => $avgMood, 'average_stress' => $avgStress, 'streak' => $this->calculateStreak($entries), 'today' => $todayEntry, ]; } private function buildDashboardCharts(array $entries): array { $recent = array_slice($entries, -30); $calendar = array_slice($entries, -365); return [ 'calendar' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'score' => $entry['evaluation']['total'], 'max' => $entry['evaluation']['max_total'], 'label' => $entry['evaluation']['label'], ]; }, $calendar), 'mood' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['mood'], ]; }, $recent), 'stress' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['stress'], ]; }, $recent), 'sport' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], 'value' => $entry['sport_minutes'] + $entry['walk_minutes'], 'sport' => $entry['sport_minutes'], 'walk' => $entry['walk_minutes'], 'sport_labels' => array_values(array_filter(array_map( static function (array $type): string { $label = (string) ($type['label'] ?? ''); $location = sport_location_label((string) ($type['location'] ?? '')); return $location !== '' ? $label . ' · ' . $location : $label; }, $entry['sport_type_meta'] ?? [] ))), 'sport_icons' => array_values(array_filter(array_map( static fn (array $type): ?string => isset($type['icon']) ? sport_icon_path((string) $type['icon']) : null, $entry['sport_type_meta'] ?? [] ))), 'sport_bonus' => (float) ($entry['evaluation']['components']['sport_bonus'] ?? 0), ]; }, $recent), ]; } private function calculateStreak(array $entries): int { if ($entries === []) { return 0; } $dates = array_map(static fn (array $entry): string => $entry['date'], $entries); rsort($dates, SORT_STRING); $streak = 1; $previous = new DateTimeImmutable($dates[0]); for ($index = 1, $count = count($dates); $index < $count; $index++) { $current = new DateTimeImmutable($dates[$index]); $diff = (int) $previous->diff($current)->format('%a'); if ($diff === 1) { $streak++; $previous = $current; continue; } break; } return $streak; } private function sanitizeSettings(array $input): array { $defaults = Defaults::settings(); $settings = $defaults; $settings['scoring']['mood_multiplier'] = max(0, min(10, (int) ($input['scoring']['mood_multiplier'] ?? 3))); $settings['scoring']['energy_multiplier'] = max(0, min(10, (int) ($input['scoring']['energy_multiplier'] ?? 2))); $settings['scoring']['stress_multiplier'] = max(0, min(10, (int) ($input['scoring']['stress_multiplier'] ?? 2))); $settings['scoring']['sleep_feeling_multiplier'] = max(0, min(10, (int) ($input['scoring']['sleep_feeling_multiplier'] ?? 2))); $settings['scoring']['journal_points'] = max(0, min(20, (int) ($input['scoring']['journal_points'] ?? 2))); foreach ($defaults['scoring']['sleep_duration_points'] as $key => $default) { $settings['scoring']['sleep_duration_points'][$key] = max(0, min(20, (int) ($input['scoring']['sleep_duration_points'][$key] ?? $default))); } foreach (['sport_bands', 'walk_bands'] as $bandKey) { foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) { $settings['scoring'][$bandKey][$index] = [ 'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $defaultBand['min']))), 'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $defaultBand['max']))), 'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $defaultBand['points']))), ]; } } foreach ($defaults['ratings'] as $index => $defaultRating) { $settings['ratings'][$index] = [ 'label' => trim((string) ($input['ratings'][$index]['label'] ?? $defaultRating['label'])) ?: $defaultRating['label'], 'min' => max(0, min(999, (int) ($input['ratings'][$index]['min'] ?? $defaultRating['min']))), 'max' => max(0, min(999, (int) ($input['ratings'][$index]['max'] ?? $defaultRating['max']))), ]; } foreach ($defaults['guardrails'] as $index => $defaultGuardrail) { $energyRaw = $input['guardrails'][$index]['energy_max'] ?? $defaultGuardrail['energy_max']; $settings['guardrails'][$index] = [ 'mood_max' => max(1, min(10, (int) ($input['guardrails'][$index]['mood_max'] ?? $defaultGuardrail['mood_max']))), 'energy_max' => $energyRaw === '' || $energyRaw === null ? null : max(1, min(10, (int) $energyRaw)), 'cap_label' => trim((string) ($input['guardrails'][$index]['cap_label'] ?? $defaultGuardrail['cap_label'])) ?: $defaultGuardrail['cap_label'], ]; } $sportTypesProvided = array_key_exists('sport_types_present', $input) || array_key_exists('sport_types', $input); $settings['sport_types'] = normalized_sport_types([ 'sport_types' => $sportTypesProvided && is_array($input['sport_types'] ?? null) ? $input['sport_types'] : ($sportTypesProvided ? [] : $defaults['sport_types']), ]); return $settings; } private function hydrateSettings(array $settings): array { $settings['sport_types'] = normalized_sport_types($settings); return $settings; } private function evaluateEntriesWithContext(array $entries, array $settings): array { $normalized = array_map(fn (array $entry): array => $this->scoring->normalize($entry), $entries); usort($normalized, static fn (array $a, array $b): int => strcmp($a['date'], $b['date'])); $entryMap = []; foreach ($normalized as $entry) { $entryMap[$entry['date']] = $entry; } $evaluated = []; foreach ($normalized as $entry) { $previousEntry = $entryMap[shift_date($entry['date'], -1)] ?? null; $evaluation = $this->scoring->evaluate($entry, $settings, $previousEntry); $evaluated[] = array_merge($entry, [ 'evaluation' => $evaluation, 'sport_type_meta' => find_sport_types($settings, $entry['sport_types']), ]); } return $evaluated; } private function sendSecurityHeaders(): void { header('Referrer-Policy: strict-origin-when-cross-origin'); header('X-Frame-Options: DENY'); header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow, noarchive, nosnippet'); header('Cross-Origin-Opener-Policy: same-origin'); header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); header("Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'"); } private function enforceCsrf(): void { if (!verify_csrf($_POST['_token'] ?? null)) { http_response_code(419); exit('Ungültiges Formular-Token.'); } } private function requireUser(): array { $user = $this->auth->user(); if ($user === null) { redirect('/login'); } return $user; } private function isValidDate(string $date): bool { $parsed = DateTimeImmutable::createFromFormat('Y-m-d', $date); return $parsed !== false && $parsed->format('Y-m-d') === $date; } private function isValidUsername(string $username): bool { return preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username) === 1; } private function isStrongPassword(string $password): bool { return strlen($password) >= 10; } private function throttleKey(string $username): string { $remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); return sha1($remoteAddress . '|' . normalize_username($username)); } }