Files
mood-tracking/src/App.php
T
2026-04-11 19:13:40 +02:00

582 lines
20 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';
if (!$this->users->hasAnyUsers()) {
if ($path !== '/setup') {
redirect('/setup');
}
} elseif (!$this->auth->check() && $path !== '/login') {
redirect('/login');
}
if ($this->auth->check() && 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');
}
$user = $this->users->create($username, $password, true);
$this->auth->login($user);
flash('success', 'Der erste Account wurde erstellt. Du kannst direkt loslegen.');
redirect('/');
}
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');
}
if (!$this->auth->attempt($username, $password)) {
$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->settings->forUser($user['username']);
$entries = $this->entries->all($user['username']);
$evaluatedEntries = [];
foreach ($entries as $entry) {
$evaluation = $this->scoring->evaluate($entry, $settings);
$evaluatedEntries[] = array_merge($entry, ['evaluation' => $evaluation]);
}
usort($evaluatedEntries, static fn (array $a, array $b): int => strcmp($a['date'], $b['date']));
$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->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,
'walk_minutes' => 0,
'note' => '',
];
$entry = $this->scoring->normalize($entry);
$evaluation = $this->scoring->evaluate($entry, $settings);
View::render('track', [
'pageTitle' => 'Tag tracken',
'page' => 'track',
'authUser' => $user,
'entry' => $entry,
'evaluation' => $evaluation,
'settings' => $settings,
'trackMood' => $evaluation['sentiment'],
'topbarDate' => $entry['date'],
'trackPayload' => encode_payload([
'settings' => $settings,
'entry' => $entry,
]),
]);
}
private function handleTrack(): void
{
$this->enforceCsrf();
$user = $this->requireUser();
$settings = $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,
'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');
}
$evaluation = $this->scoring->evaluate($entry, $settings);
$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->settings->forUser($user['username']);
$selectedDate = isset($_GET['date']) ? (string) $_GET['date'] : null;
$entries = $this->entries->all($user['username']);
$archive = [];
foreach ($entries as $entry) {
$archive[] = array_merge($entry, [
'evaluation' => $this->scoring->evaluate($entry, $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->settings->forUser($user['username']);
View::render('options', [
'pageTitle' => 'Optionen',
'page' => 'options',
'authUser' => $user,
'settings' => $settings,
'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,
'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', 'Die Bewertungslogik wurde 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'],
];
}, $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'],
];
}
return $settings;
}
private function sendSecurityHeaders(): void
{
header('Referrer-Policy: strict-origin-when-cross-origin');
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
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));
}
}