582 lines
20 KiB
PHP
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));
|
|
}
|
|
}
|