first commit
This commit is contained in:
+579
@@ -0,0 +1,579 @@
|
||||
<?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 Passwoerter stimmen nicht ueberein.');
|
||||
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 pruefe Benutzername und Passwort.');
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
$this->throttle->clear($throttleKey);
|
||||
flash('success', 'Willkommen zurueck.');
|
||||
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,
|
||||
'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 waehle ein gueltiges 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 Passwoerter stimmen nicht ueberein.');
|
||||
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 fuer 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('Ungueltiges 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class EntryRepository
|
||||
{
|
||||
public function save(string $username, string $date, array $entry, array $evaluation): void
|
||||
{
|
||||
$path = $this->pathFor($username, $date);
|
||||
$directory = dirname($path);
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents($path, $this->toMarkdown($username, $date, $entry, $evaluation));
|
||||
}
|
||||
|
||||
public function find(string $username, string $date): ?array
|
||||
{
|
||||
$path = $this->pathFor($username, $date);
|
||||
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->parse((string) file_get_contents($path), $date);
|
||||
}
|
||||
|
||||
public function all(string $username): array
|
||||
{
|
||||
$directory = $this->directoryFor($username);
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($directory . '/*.txt') ?: [];
|
||||
rsort($files, SORT_STRING);
|
||||
|
||||
$entries = [];
|
||||
foreach ($files as $file) {
|
||||
$date = basename($file, '.txt');
|
||||
$parsed = $this->parse((string) file_get_contents($file), $date);
|
||||
if ($parsed !== null) {
|
||||
$entries[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function directoryFor(string $username): string
|
||||
{
|
||||
return storage_path('users/' . normalize_username($username) . '/days');
|
||||
}
|
||||
|
||||
private function pathFor(string $username, string $date): string
|
||||
{
|
||||
return $this->directoryFor($username) . '/' . $date . '.txt';
|
||||
}
|
||||
|
||||
private function parse(string $content, string $fallbackDate): ?array
|
||||
{
|
||||
$entry = [
|
||||
'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate,
|
||||
'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5),
|
||||
'energy' => (int) ($this->extract('/^- Energie:\s*(.+)$/m', $content) ?? 5),
|
||||
'stress' => (int) ($this->extract('/^- Stress:\s*(.+)$/m', $content) ?? 5),
|
||||
'sleep_hours' => (float) ($this->extract('/^- Schlafdauer:\s*(.+)$/m', $content) ?? 0),
|
||||
'sleep_feeling' => (int) ($this->extract('/^- Schlafgefuehl:\s*(.+)$/m', $content) ?? 3),
|
||||
'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0),
|
||||
'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0),
|
||||
'note' => $this->extractNote($content),
|
||||
];
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function extract(?string $pattern, string $content): ?string
|
||||
{
|
||||
if ($pattern === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preg_match($pattern, $content, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
private function extractNote(string $content): string
|
||||
{
|
||||
if (!preg_match('/^## Notiz\s*$\R?([\s\S]*)\z/m', $content, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
|
||||
private function toMarkdown(string $username, string $date, array $entry, array $evaluation): string
|
||||
{
|
||||
$lines = [
|
||||
'<!-- mood-tracker:v1 -->',
|
||||
'# Stimmungstracker',
|
||||
'Datum: ' . $date,
|
||||
'Benutzer: ' . normalize_username($username),
|
||||
'',
|
||||
'## Werte',
|
||||
'- Stimmung: ' . $entry['mood'],
|
||||
'- Energie: ' . $entry['energy'],
|
||||
'- Stress: ' . $entry['stress'],
|
||||
'- Schlafdauer: ' . $entry['sleep_hours'],
|
||||
'- Schlafgefuehl: ' . $entry['sleep_feeling'],
|
||||
'- Sport: ' . $entry['sport_minutes'],
|
||||
'- Spaziergang: ' . $entry['walk_minutes'],
|
||||
'',
|
||||
'## Bewertung',
|
||||
'- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']),
|
||||
'- Urteil: ' . $evaluation['label'],
|
||||
'',
|
||||
'## Punktedetails',
|
||||
'- Stimmung: ' . format_points((float) $evaluation['components']['mood']),
|
||||
'- Energie: ' . format_points((float) $evaluation['components']['energy']),
|
||||
'- Stress: ' . format_points((float) $evaluation['components']['stress']),
|
||||
'- Schlafdauer: ' . format_points((float) $evaluation['components']['sleep_hours']),
|
||||
'- Schlafgefuehl: ' . format_points((float) $evaluation['components']['sleep_feeling']),
|
||||
'- Sport: ' . format_points((float) $evaluation['components']['sport_minutes']),
|
||||
'- Spaziergang: ' . format_points((float) $evaluation['components']['walk_minutes']),
|
||||
'- Notiz: ' . format_points((float) $evaluation['components']['note']),
|
||||
'',
|
||||
'## Notiz',
|
||||
trim((string) $entry['note']),
|
||||
'',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LoginThrottle
|
||||
{
|
||||
private string $path;
|
||||
private int $windowSeconds = 600;
|
||||
private int $maxAttempts = 5;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->path = storage_path('system/login-throttle.json');
|
||||
}
|
||||
|
||||
public function tooManyAttempts(string $key): bool
|
||||
{
|
||||
$attempts = $this->attempts();
|
||||
$bucket = $attempts[$key] ?? [];
|
||||
|
||||
return count($bucket) >= $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function availableInSeconds(string $key): int
|
||||
{
|
||||
$attempts = $this->attempts();
|
||||
$bucket = $attempts[$key] ?? [];
|
||||
|
||||
if ($bucket === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$oldest = min($bucket);
|
||||
$wait = ($oldest + $this->windowSeconds) - time();
|
||||
|
||||
return max(0, $wait);
|
||||
}
|
||||
|
||||
public function hit(string $key): void
|
||||
{
|
||||
$attempts = $this->attempts();
|
||||
$attempts[$key] ??= [];
|
||||
$attempts[$key][] = time();
|
||||
$attempts[$key] = $this->pruneBucket($attempts[$key]);
|
||||
$this->write($attempts);
|
||||
}
|
||||
|
||||
public function clear(string $key): void
|
||||
{
|
||||
$attempts = $this->attempts();
|
||||
unset($attempts[$key]);
|
||||
$this->write($attempts);
|
||||
}
|
||||
|
||||
private function attempts(): array
|
||||
{
|
||||
$data = decode_json_file($this->path, []);
|
||||
$clean = [];
|
||||
|
||||
foreach ($data as $key => $bucket) {
|
||||
if (!is_array($bucket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pruned = $this->pruneBucket($bucket);
|
||||
if ($pruned !== []) {
|
||||
$clean[$key] = $pruned;
|
||||
}
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function pruneBucket(array $bucket): array
|
||||
{
|
||||
$threshold = time() - $this->windowSeconds;
|
||||
|
||||
return array_values(array_filter($bucket, static fn (mixed $timestamp): bool => (int) $timestamp >= $threshold));
|
||||
}
|
||||
|
||||
private function write(array $attempts): void
|
||||
{
|
||||
if (!is_dir(dirname($this->path))) {
|
||||
mkdir(dirname($this->path), 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
json_encode($attempts, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ScoringService
|
||||
{
|
||||
public function normalize(array $input): array
|
||||
{
|
||||
return [
|
||||
'date' => $input['date'] ?? today(),
|
||||
'mood' => max(1, min(10, (int) ($input['mood'] ?? 5))),
|
||||
'energy' => max(1, min(10, (int) ($input['energy'] ?? 5))),
|
||||
'stress' => max(1, min(10, (int) ($input['stress'] ?? 5))),
|
||||
'sleep_hours' => max(0, min(24, (float) ($input['sleep_hours'] ?? 0))),
|
||||
'sleep_feeling' => max(1, min(5, (int) ($input['sleep_feeling'] ?? 3))),
|
||||
'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))),
|
||||
'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))),
|
||||
'note' => trim((string) ($input['note'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function evaluate(array $entry, array $settings): array
|
||||
{
|
||||
$entry = $this->normalize($entry);
|
||||
$scoring = $settings['scoring'];
|
||||
$ratings = $this->sortedRatings($settings['ratings'] ?? []);
|
||||
|
||||
$components = [
|
||||
'mood' => $entry['mood'] * (float) $scoring['mood_multiplier'],
|
||||
'energy' => $entry['energy'] * (float) $scoring['energy_multiplier'],
|
||||
'stress' => (11 - $entry['stress']) * (float) $scoring['stress_multiplier'],
|
||||
'sleep_hours' => $this->sleepDurationPoints((float) $entry['sleep_hours'], $scoring['sleep_duration_points']),
|
||||
'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'],
|
||||
'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']),
|
||||
'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']),
|
||||
'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'],
|
||||
];
|
||||
|
||||
$total = round(array_sum($components), 1);
|
||||
$maxTotal = round(
|
||||
(10 * (float) $scoring['mood_multiplier']) +
|
||||
(10 * (float) $scoring['energy_multiplier']) +
|
||||
(10 * (float) $scoring['stress_multiplier']) +
|
||||
max(array_map('floatval', $scoring['sleep_duration_points'])) +
|
||||
(5 * (float) $scoring['sleep_feeling_multiplier']) +
|
||||
$this->maxBandPoints($scoring['sport_bands']) +
|
||||
$this->maxBandPoints($scoring['walk_bands']) +
|
||||
(float) $scoring['journal_points'],
|
||||
1
|
||||
);
|
||||
|
||||
$label = $this->labelForScore($total, $ratings);
|
||||
$guardrail = null;
|
||||
|
||||
foreach ($settings['guardrails'] ?? [] as $rule) {
|
||||
$moodMatch = $entry['mood'] <= (int) ($rule['mood_max'] ?? 10);
|
||||
$energyLimit = $rule['energy_max'] ?? null;
|
||||
$energyMatch = $energyLimit === null || $entry['energy'] <= (int) $energyLimit;
|
||||
|
||||
if ($moodMatch && $energyMatch) {
|
||||
$capped = $this->capLabel($label, (string) ($rule['cap_label'] ?? $label), $ratings);
|
||||
if ($capped !== $label) {
|
||||
$guardrail = (string) ($rule['cap_label'] ?? '');
|
||||
$label = $capped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'components' => $components,
|
||||
'total' => $total,
|
||||
'max_total' => $maxTotal,
|
||||
'label' => $label,
|
||||
'guardrail' => $guardrail,
|
||||
'percentage' => $maxTotal > 0 ? round(($total / $maxTotal) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function sleepDurationPoints(float $hours, array $points): float
|
||||
{
|
||||
if ($hours < 4) {
|
||||
return (float) ($points['lt4'] ?? 0);
|
||||
}
|
||||
|
||||
if ($hours >= 10) {
|
||||
return (float) ($points['h10plus'] ?? 0);
|
||||
}
|
||||
|
||||
$anchors = [
|
||||
4.0 => (float) ($points['h4'] ?? 0),
|
||||
5.0 => (float) ($points['h5'] ?? 0),
|
||||
6.0 => (float) ($points['h6'] ?? 0),
|
||||
7.0 => (float) ($points['h7'] ?? 0),
|
||||
8.0 => (float) ($points['h8'] ?? 0),
|
||||
9.0 => (float) ($points['h9'] ?? 0),
|
||||
10.0 => (float) ($points['h10plus'] ?? 0),
|
||||
];
|
||||
|
||||
$lowerHour = floor($hours);
|
||||
$upperHour = ceil($hours);
|
||||
|
||||
if ($lowerHour === $upperHour) {
|
||||
return (float) ($anchors[(float) $lowerHour] ?? 0);
|
||||
}
|
||||
|
||||
$lowerPoints = $anchors[(float) $lowerHour] ?? 0.0;
|
||||
$upperPoints = $anchors[(float) $upperHour] ?? 0.0;
|
||||
$fraction = $hours - $lowerHour;
|
||||
|
||||
return round($lowerPoints + (($upperPoints - $lowerPoints) * $fraction), 1);
|
||||
}
|
||||
|
||||
private function bandPoints(int $value, array $bands): float
|
||||
{
|
||||
foreach ($bands as $band) {
|
||||
if ($value >= (int) ($band['min'] ?? 0) && $value <= (int) ($band['max'] ?? 0)) {
|
||||
return (float) ($band['points'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$last = end($bands);
|
||||
|
||||
return (float) ($last['points'] ?? 0);
|
||||
}
|
||||
|
||||
private function maxBandPoints(array $bands): float
|
||||
{
|
||||
$max = 0.0;
|
||||
|
||||
foreach ($bands as $band) {
|
||||
$max = max($max, (float) ($band['points'] ?? 0));
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private function sortedRatings(array $ratings): array
|
||||
{
|
||||
usort($ratings, static fn (array $a, array $b): int => ((int) $a['min']) <=> ((int) $b['min']));
|
||||
|
||||
return $ratings;
|
||||
}
|
||||
|
||||
private function labelForScore(float $score, array $ratings): string
|
||||
{
|
||||
foreach ($ratings as $rating) {
|
||||
if ($score >= (float) $rating['min'] && $score <= (float) $rating['max']) {
|
||||
return (string) $rating['label'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($score < (float) ($ratings[0]['min'] ?? 0)) {
|
||||
return (string) ($ratings[0]['label'] ?? 'unbewertet');
|
||||
}
|
||||
|
||||
return (string) ($ratings[count($ratings) - 1]['label'] ?? 'unbewertet');
|
||||
}
|
||||
|
||||
private function capLabel(string $current, string $cap, array $ratings): string
|
||||
{
|
||||
$order = array_map(static fn (array $rating): string => (string) $rating['label'], $ratings);
|
||||
$currentIndex = array_search($current, $order, true);
|
||||
$capIndex = array_search($cap, $order, true);
|
||||
|
||||
if ($currentIndex === false || $capIndex === false) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
return $currentIndex > $capIndex ? $cap : $current;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SettingsRepository
|
||||
{
|
||||
public function forUser(string $username): array
|
||||
{
|
||||
$path = $this->pathFor($username);
|
||||
$saved = decode_json_file($path, []);
|
||||
|
||||
return array_replace_recursive(Defaults::settings(), $saved);
|
||||
}
|
||||
|
||||
public function saveForUser(string $username, array $settings): void
|
||||
{
|
||||
$path = $this->pathFor($username);
|
||||
$directory = dirname($path);
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
private function pathFor(string $username): string
|
||||
{
|
||||
return storage_path('users/' . normalize_username($username) . '/settings.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class UserRepository
|
||||
{
|
||||
private string $path;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->path = storage_path('system/users.json');
|
||||
}
|
||||
|
||||
public function hasAnyUsers(): bool
|
||||
{
|
||||
return count($this->all()) > 0;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
$data = decode_json_file($this->path, ['users' => []]);
|
||||
|
||||
return array_values(array_filter($data['users'] ?? [], 'is_array'));
|
||||
}
|
||||
|
||||
public function find(string $username): ?array
|
||||
{
|
||||
$needle = normalize_username($username);
|
||||
|
||||
foreach ($this->all() as $user) {
|
||||
if (($user['username'] ?? '') === $needle) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function verify(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->find($username);
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!password_verify($password, (string) ($user['password_hash'] ?? ''))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, bool $isAdmin = false): array
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
|
||||
if ($normalized === '' || $this->find($normalized) !== null) {
|
||||
throw new RuntimeException('Benutzername existiert bereits oder ist ungueltig.');
|
||||
}
|
||||
|
||||
$users = $this->all();
|
||||
$users[] = [
|
||||
'username' => $normalized,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'is_admin' => $isAdmin,
|
||||
'created_at' => date(DATE_ATOM),
|
||||
];
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
|
||||
return $this->find($normalized) ?? [];
|
||||
}
|
||||
|
||||
public function changePassword(string $username, string $password): void
|
||||
{
|
||||
$normalized = normalize_username($username);
|
||||
$users = $this->all();
|
||||
|
||||
foreach ($users as &$user) {
|
||||
if (($user['username'] ?? '') === $normalized) {
|
||||
$user['password_hash'] = password_hash($password, PASSWORD_DEFAULT);
|
||||
$user['updated_at'] = date(DATE_ATOM);
|
||||
}
|
||||
}
|
||||
unset($user);
|
||||
|
||||
$this->write(['users' => $users]);
|
||||
}
|
||||
|
||||
private function write(array $payload): void
|
||||
{
|
||||
if (!is_dir(dirname($this->path))) {
|
||||
mkdir(dirname($this->path), 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->path,
|
||||
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(private UserRepository $users)
|
||||
{
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
return isset($_SESSION['user']) && is_array($_SESSION['user']);
|
||||
}
|
||||
|
||||
public function user(): ?array
|
||||
{
|
||||
if (!$this->check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $_SESSION['user'];
|
||||
}
|
||||
|
||||
public function attempt(string $username, string $password): bool
|
||||
{
|
||||
$user = $this->users->verify($username, $password);
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->login($user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function login(array $user): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
|
||||
$_SESSION['user'] = [
|
||||
'username' => $user['username'],
|
||||
'is_admin' => (bool) ($user['is_admin'] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
unset($_SESSION['user']);
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Defaults
|
||||
{
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'labels' => [
|
||||
'sleep_feeling' => [
|
||||
1 => 'hundemüde',
|
||||
2 => 'müde',
|
||||
3 => 'geht so',
|
||||
4 => 'ausgeschlafen',
|
||||
5 => 'sehr ausgeschlafen',
|
||||
],
|
||||
],
|
||||
'scoring' => [
|
||||
'mood_multiplier' => 3,
|
||||
'energy_multiplier' => 2,
|
||||
'stress_multiplier' => 2,
|
||||
'sleep_feeling_multiplier' => 2,
|
||||
'sleep_duration_points' => [
|
||||
'lt4' => 0,
|
||||
'h4' => 2,
|
||||
'h5' => 5,
|
||||
'h6' => 8,
|
||||
'h7' => 10,
|
||||
'h8' => 9,
|
||||
'h9' => 7,
|
||||
'h10plus' => 5,
|
||||
],
|
||||
'sport_bands' => [
|
||||
['min' => 0, 'max' => 0, 'points' => 0],
|
||||
['min' => 1, 'max' => 20, 'points' => 2],
|
||||
['min' => 21, 'max' => 45, 'points' => 5],
|
||||
['min' => 46, 'max' => 10000, 'points' => 7],
|
||||
],
|
||||
'walk_bands' => [
|
||||
['min' => 0, 'max' => 0, 'points' => 0],
|
||||
['min' => 1, 'max' => 15, 'points' => 2],
|
||||
['min' => 16, 'max' => 40, 'points' => 5],
|
||||
['min' => 41, 'max' => 10000, 'points' => 7],
|
||||
],
|
||||
'journal_points' => 2,
|
||||
],
|
||||
'ratings' => [
|
||||
['label' => 'Scheißtag', 'min' => 0, 'max' => 39],
|
||||
['label' => 'schwerer Tag', 'min' => 40, 'max' => 54],
|
||||
['label' => 'okayer Tag', 'min' => 55, 'max' => 69],
|
||||
['label' => 'guter Tag', 'min' => 70, 'max' => 84],
|
||||
['label' => 'super Tag', 'min' => 85, 'max' => 106],
|
||||
],
|
||||
'guardrails' => [
|
||||
[
|
||||
'mood_max' => 3,
|
||||
'energy_max' => null,
|
||||
'cap_label' => 'okayer Tag',
|
||||
],
|
||||
[
|
||||
'mood_max' => 2,
|
||||
'energy_max' => 3,
|
||||
'cap_label' => 'schwerer Tag',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class View
|
||||
{
|
||||
public static function render(string $template, array $data = []): void
|
||||
{
|
||||
$pageTitle = $data['pageTitle'] ?? 'Mood';
|
||||
$page = $data['page'] ?? 'dashboard';
|
||||
$authUser = $data['authUser'] ?? null;
|
||||
$flashes = pull_flashes();
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
|
||||
ob_start();
|
||||
require base_path('templates/pages/' . $template . '.php');
|
||||
$content = (string) ob_get_clean();
|
||||
|
||||
require base_path('templates/layout.php');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/helpers.php';
|
||||
require __DIR__ . '/Support/Defaults.php';
|
||||
require __DIR__ . '/Support/Auth.php';
|
||||
require __DIR__ . '/Support/View.php';
|
||||
require __DIR__ . '/Domain/UserRepository.php';
|
||||
require __DIR__ . '/Domain/SettingsRepository.php';
|
||||
require __DIR__ . '/Domain/EntryRepository.php';
|
||||
require __DIR__ . '/Domain/LoginThrottle.php';
|
||||
require __DIR__ . '/Domain/ScoringService.php';
|
||||
require __DIR__ . '/App.php';
|
||||
|
||||
date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin');
|
||||
|
||||
$isSecure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
|
||||
session_name('mood_session');
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
foreach ([
|
||||
storage_path(),
|
||||
storage_path('system'),
|
||||
storage_path('users'),
|
||||
] as $directory) {
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function base_path(string $path = ''): string
|
||||
{
|
||||
$base = dirname(__DIR__);
|
||||
|
||||
if ($path === '') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function storage_path(string $path = ''): string
|
||||
{
|
||||
return base_path('storage/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
function e(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function redirect(string $path): never
|
||||
{
|
||||
header('Location: ' . $path);
|
||||
exit;
|
||||
}
|
||||
|
||||
function request_path(): string
|
||||
{
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$path = is_string($path) ? $path : '/';
|
||||
|
||||
if ($path !== '/') {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
|
||||
return $path === '' ? '/' : $path;
|
||||
}
|
||||
|
||||
function flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['_flash'][] = [
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
function pull_flashes(): array
|
||||
{
|
||||
$flashes = $_SESSION['_flash'] ?? [];
|
||||
unset($_SESSION['_flash']);
|
||||
|
||||
return is_array($flashes) ? $flashes : [];
|
||||
}
|
||||
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['_csrf'])) {
|
||||
$_SESSION['_csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return (string) $_SESSION['_csrf'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="_token" value="' . e(csrf_token()) . '">';
|
||||
}
|
||||
|
||||
function verify_csrf(?string $token): bool
|
||||
{
|
||||
if (!is_string($token) || $token === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(csrf_token(), $token);
|
||||
}
|
||||
|
||||
function is_active_path(string $path): bool
|
||||
{
|
||||
return request_path() === $path;
|
||||
}
|
||||
|
||||
function format_points(float $value): string
|
||||
{
|
||||
$rounded = round($value, 1);
|
||||
|
||||
if (abs($rounded - round($rounded)) < 0.05) {
|
||||
return (string) (int) round($rounded);
|
||||
}
|
||||
|
||||
return number_format($rounded, 1, ',', '.');
|
||||
}
|
||||
|
||||
function normalize_username(string $username): string
|
||||
{
|
||||
return strtolower(trim($username));
|
||||
}
|
||||
|
||||
function today(): string
|
||||
{
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
function decode_json_file(string $path, array $fallback = []): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : $fallback;
|
||||
}
|
||||
|
||||
function encode_payload(array $payload): string
|
||||
{
|
||||
return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user