Files
mood-tracking/src/App.php
T

670 lines
24 KiB
PHP

<?php
declare(strict_types=1);
final class App
{
private UserRepository $users;
private SettingsRepository $settings;
private EntryRepository $entries;
private LoginThrottle $throttle;
private ScoringService $scoring;
private Auth $auth;
public function __construct()
{
$this->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));
}
}