diff --git a/.htaccess b/.htaccess index 365773d..dd178b0 100644 --- a/.htaccess +++ b/.htaccess @@ -1,5 +1,6 @@ Options -Indexes DirectoryIndex index.php +AddType application/manifest+json .webmanifest RewriteEngine On @@ -11,4 +12,3 @@ DirectoryIndex index.php RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L] - diff --git a/assets/branding/apple-touch-icon.svg b/assets/branding/apple-touch-icon.svg new file mode 100644 index 0000000..73145e8 --- /dev/null +++ b/assets/branding/apple-touch-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/css/app.css b/assets/css/app.css index 0706eb5..1bb9a87 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -144,6 +144,12 @@ button { cursor: pointer; } +button:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none !important; +} + .aurora { position: fixed; inset: auto; @@ -314,6 +320,10 @@ button { margin-top: 2rem; } +.mobile-nav { + display: none; +} + .main-nav a { display: flex; align-items: center; @@ -1014,6 +1024,30 @@ input[type="range"] { margin-top: 0.95rem; } +.push-panel { + display: grid; + gap: 0.9rem; +} + +.push-panel h5 { + margin: 0 0 0.35rem; + font-size: 1rem; +} + +.push-panel [data-push-status][data-tone="success"] { + color: var(--good); +} + +.push-panel [data-push-status][data-tone="error"] { + color: var(--danger); +} + +.push-actions { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + .primary-button, .ghost-button, .button-link { @@ -1239,6 +1273,14 @@ input[type="range"] { gap: 0.7rem; } +.checkbox-row--panel { + padding: 0.95rem 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.1); + min-height: 100%; +} + .checkbox-row input { width: auto; } @@ -1340,6 +1382,10 @@ input[type="range"] { } @media (max-width: 820px) { + .shell { + grid-template-columns: 1fr; + } + .topbar, .section-head, .form-actions { @@ -1362,6 +1408,53 @@ input[type="range"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sidebar { + display: none; + } + + .mobile-nav { + position: fixed; + left: 0.8rem; + right: 0.8rem; + bottom: max(0.8rem, env(safe-area-inset-bottom)); + z-index: 30; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.45rem; + padding: 0.6rem; + border-radius: 28px; + } + + .mobile-nav a { + display: grid; + justify-items: center; + gap: 0.35rem; + padding: 0.7rem 0.5rem; + border-radius: 22px; + color: var(--muted); + transition: transform 180ms ease, background 180ms ease, color 180ms ease; + } + + .mobile-nav a.active { + background: var(--nav-hover-bg); + color: var(--text); + transform: translateY(-1px); + } + + .mobile-nav a span { + font-size: 0.76rem; + line-height: 1.1; + } + + .mobile-nav .nav-icon { + width: 1.25rem; + height: 1.25rem; + } + + body.is-authenticated .content { + padding-bottom: calc(6.8rem + env(safe-area-inset-bottom)); + } + .bar-chart { overflow-x: auto; padding-bottom: 0.4rem; @@ -1426,4 +1519,20 @@ input[type="range"] { .bar-chart { min-height: 9.5rem; } + + .mobile-nav { + left: 0.7rem; + right: 0.7rem; + bottom: max(0.7rem, env(safe-area-inset-bottom)); + padding: 0.5rem; + gap: 0.3rem; + } + + .mobile-nav a { + padding: 0.62rem 0.35rem; + } + + .mobile-nav a span { + font-size: 0.72rem; + } } diff --git a/assets/js/app.js b/assets/js/app.js index 6410f0b..5258972 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -113,6 +113,38 @@ return last ? Number(last.points || 0) : 0; } + function stepTargetPoints(value, targets) { + const list = [...(targets || [])].sort((a, b) => Number(a.steps || 0) - Number(b.steps || 0)); + if (!list.length) { + return 0; + } + + if (value <= Number(list[0].steps || 0)) { + return Number(list[0].points || 0); + } + + const last = list[list.length - 1]; + if (value >= Number(last.steps || 0)) { + return Number(last.points || 0); + } + + for (let index = 1; index < list.length; index += 1) { + const previous = list[index - 1]; + const current = list[index]; + const previousSteps = Number(previous.steps || 0); + const currentSteps = Number(current.steps || 0); + + if (value > currentSteps) { + continue; + } + + const ratio = (value - previousSteps) / Math.max(currentSteps - previousSteps, 1); + return Math.round((Number(previous.points || 0) + ((Number(current.points || 0) - Number(previous.points || 0)) * ratio)) * 10) / 10; + } + + return 0; + } + function sortedRatings(ratings) { return [...(ratings || [])].sort((a, b) => Number(a.min || 0) - Number(b.min || 0)); } @@ -231,6 +263,7 @@ function evaluateEntry(entry, settings, previousEntry = null) { const ratings = sortedRatings(settings.ratings || []); const scoring = settings.scoring || {}; + const walkMode = entry.walk_mode === "steps" ? "steps" : "time"; const components = { mood: Number(entry.mood) * Number(scoring.mood_multiplier || 0), energy: Number(entry.energy) * Number(scoring.energy_multiplier || 0), @@ -239,7 +272,9 @@ sleep_feeling: Number(entry.sleep_feeling) * Number(scoring.sleep_feeling_multiplier || 0), sport_minutes: bandPoints(Number(entry.sport_minutes), scoring.sport_bands || []), sport_bonus: sportBonusPoints(entry, settings, previousEntry), - walk_minutes: bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), + walk_minutes: walkMode === "steps" + ? stepTargetPoints(Number(entry.walk_steps || 0), scoring.walk_step_targets || []) + : bandPoints(Number(entry.walk_minutes), scoring.walk_bands || []), note: String(entry.note || "").trim() === "" ? 0 : Number(scoring.journal_points || 0), }; @@ -297,7 +332,9 @@ sleep_feeling: Number(form.elements.sleep_feeling.value), sport_minutes: Number(form.elements.sport_minutes.value || 0), sport_types: [...form.querySelectorAll('input[name="sport_types[]"]:checked')].map(input => input.value), - walk_minutes: Number(form.elements.walk_minutes.value || 0), + walk_mode: form.elements.walk_mode?.value || "time", + walk_minutes: Number(form.elements.walk_minutes?.value || 0), + walk_steps: Number(form.elements.walk_steps?.value || 0), note: form.elements.note.value || "", }); @@ -452,6 +489,7 @@ const sportY = walkY - sportHeight; const badgeY = total > 0 ? Math.max(backgroundY + 6, sportY - 24) : (baseY - 20); const labels = Array.isArray(item.sport_labels) ? item.sport_labels.filter(Boolean) : []; + const walkLabel = item.walk_label || `${walk} Aktivität`; const label = labels.length ? ` · ${labels.join(", ")}` : ""; const bonus = Number(item.sport_bonus || 0); const icons = Array.isArray(item.sport_icons) ? item.sport_icons.slice(0, 3) : []; @@ -469,7 +507,7 @@ return ` - ${formatDateLabel(item.date)} · Spaziergang ${walk} min + ${formatDateLabel(item.date)} · Spaziergang ${walkLabel} ${formatDateLabel(item.date)} · Sport ${sport} min${label}${bonus > 0 ? ` · Bonus ${formatNumber(bonus)}` : ""} @@ -898,6 +936,200 @@ syncPresets(); } + function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") || ""; + } + + function pushPublicKey() { + return document.querySelector('meta[name="mood-push-public-key"]')?.getAttribute("content") || ""; + } + + function base64UrlToUint8Array(value) { + const padded = value + "=".repeat((4 - (value.length % 4)) % 4); + const base64 = padded.replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + + return Uint8Array.from(raw, char => char.charCodeAt(0)); + } + + async function postJson(url, payload) { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken(), + }, + body: JSON.stringify(payload || {}), + }); + + let data = {}; + try { + data = await response.json(); + } catch (error) { + data = {}; + } + + if (!response.ok) { + throw new Error(data.message || "Die Anfrage konnte nicht verarbeitet werden."); + } + + return data; + } + + async function initPwaShell() { + if (document.body.dataset.authenticated !== "1" || !("serviceWorker" in navigator)) { + return; + } + + try { + await navigator.serviceWorker.register("/service-worker.js"); + } catch (error) { + console.warn("Service Worker registration failed", error); + } + } + + function isStandaloneMode() { + return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; + } + + function initPushControls() { + const panel = document.querySelector("[data-push-panel]"); + if (!panel) { + return; + } + + const statusNode = panel.querySelector("[data-push-status]"); + const enableButton = panel.querySelector("[data-push-enable]"); + const disableButton = panel.querySelector("[data-push-disable]"); + const testButton = panel.querySelector("[data-push-test]"); + const ready = panel.dataset.pushReady === "1"; + const vapidKey = pushPublicKey(); + + const setStatus = (message, tone = "neutral") => { + if (!statusNode) { + return; + } + + statusNode.textContent = message; + statusNode.dataset.tone = tone; + }; + + if (!ready || !vapidKey) { + setStatus("Push ist auf diesem Server gerade noch nicht bereit.", "error"); + return; + } + + if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) { + setStatus("Dieses Gerät unterstützt Web Push in diesem Browser leider nicht.", "error"); + if (enableButton) enableButton.disabled = true; + if (disableButton) disableButton.disabled = true; + if (testButton) testButton.disabled = true; + return; + } + + const updateUi = subscription => { + if (subscription) { + setStatus("Push ist auf diesem Gerät aktiv. Test und tägliche Erinnerungen können gesendet werden.", "success"); + if (enableButton) enableButton.disabled = true; + if (disableButton) disableButton.disabled = false; + if (testButton) testButton.disabled = false; + return; + } + + if (!isStandaloneMode() && /iPhone|iPad|iPod/i.test(navigator.userAgent)) { + setStatus("Für iPhone zuerst in Safari öffnen, zum Home-Bildschirm hinzufügen und danach Push aktivieren.", "neutral"); + } else { + setStatus("Push ist auf diesem Gerät noch nicht aktiv. Du kannst ihn hier direkt einschalten.", "neutral"); + } + + if (enableButton) enableButton.disabled = false; + if (disableButton) disableButton.disabled = true; + if (testButton) testButton.disabled = true; + }; + + const getRegistration = async () => { + await initPwaShell(); + return navigator.serviceWorker.ready; + }; + + const getSubscription = async () => { + const registration = await getRegistration(); + return registration.pushManager.getSubscription(); + }; + + const refreshStatus = async () => { + try { + updateUi(await getSubscription()); + } catch (error) { + setStatus("Der Push-Status konnte gerade nicht gelesen werden.", "error"); + } + }; + + if (enableButton) { + enableButton.addEventListener("click", async () => { + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setStatus("Die Push-Berechtigung wurde nicht erteilt.", "error"); + return; + } + + const registration = await getRegistration(); + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: base64UrlToUint8Array(vapidKey), + }); + } + + await postJson("/push/subscribe", { + subscription: subscription.toJSON(), + contentEncoding: subscription.options?.applicationServerKey ? "aes128gcm" : "aes128gcm", + }); + + updateUi(subscription); + } catch (error) { + setStatus(error.message || "Push konnte nicht aktiviert werden.", "error"); + } + }); + } + + if (disableButton) { + disableButton.addEventListener("click", async () => { + try { + const subscription = await getSubscription(); + if (!subscription) { + updateUi(null); + return; + } + + await postJson("/push/unsubscribe", { + endpoint: subscription.endpoint, + }); + await subscription.unsubscribe(); + updateUi(null); + } catch (error) { + setStatus(error.message || "Push konnte nicht entfernt werden.", "error"); + } + }); + } + + if (testButton) { + testButton.addEventListener("click", async () => { + try { + const data = await postJson("/push/test", {}); + setStatus(data.message || "Die Test-Benachrichtigung wurde verschickt.", "success"); + } catch (error) { + setStatus(error.message || "Die Test-Benachrichtigung konnte nicht gesendet werden.", "error"); + } + }); + } + + refreshStatus(); + } + window.addEventListener("resize", () => { if (!document.querySelector("#calendar-heatmap")) { return; @@ -914,4 +1146,6 @@ initTrackPreview(); initDashboardCharts(); initSportTypeManager(); + initPwaShell(); + initPushControls(); })(); diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..9c7a79b --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,27 @@ +{ + "id": "/", + "name": "Mood-Board", + "short_name": "Mood", + "description": "Persönlicher Stimmungstracker mit Archiv, Dashboard und Erinnerungen.", + "lang": "de-DE", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0b1e2e", + "theme_color": "#0b1e2e", + "icons": [ + { + "src": "/assets/branding/logo-mark.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/assets/branding/apple-touch-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..2201dfb --- /dev/null +++ b/service-worker.js @@ -0,0 +1,55 @@ +self.addEventListener("install", event => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener("activate", event => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", event => { + let payload = {}; + + try { + payload = event.data ? event.data.json() : {}; + } catch (error) { + payload = {}; + } + + const title = payload.title || "Mood-Board"; + const options = { + body: payload.body || "Zeit für deinen heutigen Eintrag.", + icon: payload.icon || "/assets/branding/logo-mark.svg", + badge: payload.badge || "/assets/branding/favicon.svg", + tag: payload.tag || "mood-reminder", + data: { + url: payload.url || "/track", + }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener("notificationclick", event => { + event.notification.close(); + + const targetUrl = event.notification.data?.url || "/track"; + + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + + for (const client of clients) { + const clientUrl = new URL(client.url); + const target = new URL(targetUrl, self.location.origin); + + if (clientUrl.pathname === target.pathname) { + await client.focus(); + return; + } + } + + await self.clients.openWindow(targetUrl); + })()); +}); diff --git a/src/App.php b/src/App.php index aad933b..063ddc6 100644 --- a/src/App.php +++ b/src/App.php @@ -10,6 +10,8 @@ final class App private LoginThrottle $throttle; private ScoringService $scoring; private Auth $auth; + private NotificationRepository $notifications; + private WebPushService $webPush; public function __construct() { @@ -19,6 +21,8 @@ final class App $this->throttle = new LoginThrottle(); $this->scoring = new ScoringService(); $this->auth = new Auth($this->users); + $this->notifications = new NotificationRepository(); + $this->webPush = new WebPushService($this->notifications); } public function run(): void @@ -29,6 +33,7 @@ final class App $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $hasUsers = $this->users->hasAnyUsers(); $isAuthenticated = $this->auth->check(); + $systemPaths = ['/reminders/run']; // A failed setup must never leave the app in a half-authenticated redirect loop. if (!$hasUsers && $isAuthenticated) { @@ -39,13 +44,13 @@ final class App if (!$hasUsers) { if ($path === '/login') { $path = '/setup'; - } elseif ($path !== '/setup') { + } elseif ($path !== '/setup' && !in_array($path, $systemPaths, true)) { redirect('/setup'); } } elseif (!$isAuthenticated) { if ($path === '/setup') { $path = '/login'; - } elseif ($path !== '/login') { + } elseif ($path !== '/login' && !in_array($path, $systemPaths, true)) { redirect('/login'); } } @@ -90,6 +95,37 @@ final class App $method === 'POST' ? $this->handleOptions() : $this->showOptions(); return; + case '/push/subscribe': + if ($method !== 'POST') { + http_response_code(405); + exit('Method Not Allowed'); + } + + $this->handlePushSubscribe(); + return; + + case '/push/unsubscribe': + if ($method !== 'POST') { + http_response_code(405); + exit('Method Not Allowed'); + } + + $this->handlePushUnsubscribe(); + return; + + case '/push/test': + if ($method !== 'POST') { + http_response_code(405); + exit('Method Not Allowed'); + } + + $this->handlePushTest(); + return; + + case '/reminders/run': + $this->handleReminderRun(); + return; + default: http_response_code(404); View::render('not-found', [ @@ -221,7 +257,9 @@ final class App 'sport_minutes' => 0, 'sport_type' => '', 'sport_types' => [], + 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => 0, + 'walk_steps' => 0, 'note' => '', ]; @@ -263,7 +301,9 @@ final class App 'sleep_feeling' => $_POST['sleep_feeling'] ?? 3, 'sport_minutes' => $_POST['sport_minutes'] ?? 0, 'sport_types' => $_POST['sport_types'] ?? [], + 'walk_mode' => $_POST['walk_mode'] ?? ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => $_POST['walk_minutes'] ?? 0, + 'walk_steps' => $_POST['walk_steps'] ?? 0, 'note' => $_POST['note'] ?? '', ]); @@ -323,6 +363,16 @@ final class App return true; } )); + $pushAvailable = $this->webPush->isAvailable(); + $pushPublicKey = null; + + if ($pushAvailable) { + try { + $pushPublicKey = $this->webPush->publicKey(); + } catch (RuntimeException) { + $pushAvailable = false; + } + } View::render('options', [ 'pageTitle' => 'Optionen', @@ -331,6 +381,10 @@ final class App 'settings' => $settings, 'sportTypePresets' => $sportTypePresets, 'sportLocationOptions' => sport_location_options(), + 'walkModeOptions' => walk_mode_options(), + 'pushAvailable' => $pushAvailable, + 'pushPublicKey' => $pushPublicKey, + 'pushSubscriptionCount' => $this->notifications->subscriptionCount($user['username']), 'users' => $user['is_admin'] ? $this->users->all() : [], 'maxScore' => $this->scoring->evaluate([ 'mood' => 10, @@ -343,7 +397,9 @@ final class App static fn (array $type): string => (string) ($type['id'] ?? ''), normalized_sport_types($settings) ), + 'walk_mode' => (string) ($settings['walk']['mode'] ?? 'time'), 'walk_minutes' => 999, + 'walk_steps' => 10000, 'note' => 'x', ], $settings)['max_total'], ]); @@ -357,7 +413,8 @@ final class App $form = (string) ($_POST['form_name'] ?? ''); if ($form === 'settings') { - $settings = $this->sanitizeSettings($_POST['settings'] ?? []); + $currentSettings = $this->hydrateSettings($this->settings->forUser($user['username'])); + $settings = $this->sanitizeSettings($_POST['settings'] ?? [], $currentSettings); $this->settings->saveForUser($user['username'], $settings); flash('success', 'Deine persönlichen Optionen wurden aktualisiert.'); redirect('/options'); @@ -478,9 +535,10 @@ final class App 'sport' => array_map(static function (array $entry): array { return [ 'date' => $entry['date'], - 'value' => $entry['sport_minutes'] + $entry['walk_minutes'], + 'value' => $entry['sport_minutes'] + walk_chart_value($entry), 'sport' => $entry['sport_minutes'], - 'walk' => $entry['walk_minutes'], + 'walk' => walk_chart_value($entry), + 'walk_label' => format_walk_value($entry), 'sport_labels' => array_values(array_filter(array_map( static function (array $type): string { $label = (string) ($type['label'] ?? ''); @@ -528,10 +586,14 @@ final class App return $streak; } - private function sanitizeSettings(array $input): array + private function sanitizeSettings(array $input, ?array $existingSettings = null): array { $defaults = Defaults::settings(); - $settings = $defaults; + $settings = array_replace_recursive($defaults, $existingSettings ?? []); + + $settings['walk'] = [ + 'mode' => ($input['walk']['mode'] ?? ($settings['walk']['mode'] ?? 'time')) === 'steps' ? 'steps' : 'time', + ]; $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))); @@ -545,10 +607,12 @@ final class App foreach (['sport_bands', 'walk_bands'] as $bandKey) { foreach ($defaults['scoring'][$bandKey] as $index => $defaultBand) { + $currentBand = $settings['scoring'][$bandKey][$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']))), + 'min' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['min'] ?? $currentBand['min'] ?? $defaultBand['min']))), + 'max' => max(0, min(2000, (int) ($input['scoring'][$bandKey][$index]['max'] ?? $currentBand['max'] ?? $defaultBand['max']))), + 'points' => max(0, min(20, (int) ($input['scoring'][$bandKey][$index]['points'] ?? $currentBand['points'] ?? $defaultBand['points']))), ]; } } @@ -579,12 +643,30 @@ final class App : ($sportTypesProvided ? [] : $defaults['sport_types']), ]); + $time = trim((string) ($input['notifications']['time'] ?? ($settings['notifications']['time'] ?? $defaults['notifications']['time']))); + if (!$this->isValidTime($time)) { + $time = $defaults['notifications']['time']; + } + + $settings['notifications'] = [ + 'enabled' => isset($input['notifications']['enabled']) && $input['notifications']['enabled'] === '1', + 'time' => $time, + ]; + return $settings; } private function hydrateSettings(array $settings): array { $settings['sport_types'] = normalized_sport_types($settings); + $settings['walk'] = array_replace( + Defaults::settings()['walk'], + is_array($settings['walk'] ?? null) ? $settings['walk'] : [] + ); + $settings['notifications'] = array_replace( + Defaults::settings()['notifications'], + is_array($settings['notifications'] ?? null) ? $settings['notifications'] : [] + ); return $settings; } @@ -632,6 +714,16 @@ final class App } } + private function enforceRequestCsrf(): void + { + if (!verify_request_csrf()) { + json_response([ + 'ok' => false, + 'message' => 'Ungültiges Formular-Token.', + ], 419); + } + } + private function requireUser(): array { $user = $this->auth->user(); @@ -660,10 +752,220 @@ final class App return strlen($password) >= 10; } + private function isValidTime(string $time): bool + { + return preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time) === 1; + } + private function throttleKey(string $username): string { $remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); return sha1($remoteAddress . '|' . normalize_username($username)); } + + private function handlePushSubscribe(): void + { + $this->enforceRequestCsrf(); + + $user = $this->requireUser(); + $payload = request_json_body(); + $subscription = $payload['subscription'] ?? null; + + if (!is_array($subscription)) { + json_response(['ok' => false, 'message' => 'Die Push-Daten fehlen.'], 422); + } + + try { + $this->notifications->saveSubscription($user['username'], [ + 'endpoint' => trim((string) ($subscription['endpoint'] ?? '')), + 'keys' => [ + 'p256dh' => trim((string) ($subscription['keys']['p256dh'] ?? '')), + 'auth' => trim((string) ($subscription['keys']['auth'] ?? '')), + ], + 'content_encoding' => trim((string) ($payload['contentEncoding'] ?? 'aes128gcm')), + 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), + ]); + } catch (RuntimeException $exception) { + json_response(['ok' => false, 'message' => $exception->getMessage()], 422); + } + + json_response([ + 'ok' => true, + 'message' => 'Push ist auf diesem Gerät aktiviert.', + 'count' => $this->notifications->subscriptionCount($user['username']), + ]); + } + + private function handlePushUnsubscribe(): void + { + $this->enforceRequestCsrf(); + + $user = $this->requireUser(); + $payload = request_json_body(); + $endpoint = trim((string) ($payload['endpoint'] ?? '')); + + if ($endpoint === '') { + json_response(['ok' => false, 'message' => 'Kein Push-Endpunkt übergeben.'], 422); + } + + $this->notifications->removeSubscription($user['username'], $endpoint); + + json_response([ + 'ok' => true, + 'message' => 'Push wurde für dieses Gerät entfernt.', + 'count' => $this->notifications->subscriptionCount($user['username']), + ]); + } + + private function handlePushTest(): void + { + $this->enforceRequestCsrf(); + + $user = $this->requireUser(); + $result = $this->sendNotificationsForUser($user['username'], [ + 'title' => 'Mood-Board', + 'body' => 'Die Push-Erinnerung ist auf diesem Gerät bereit.', + 'url' => '/options', + 'tag' => 'mood-push-test', + ]); + + if ($result['sent'] <= 0) { + json_response([ + 'ok' => false, + 'message' => 'Es konnte noch keine Test-Benachrichtigung gesendet werden. Bitte aktiviere Push zuerst auf diesem Gerät.', + 'removed' => $result['removed'], + ], 422); + } + + json_response([ + 'ok' => true, + 'message' => 'Die Test-Benachrichtigung wurde verschickt.', + 'sent' => $result['sent'], + 'removed' => $result['removed'], + ]); + } + + private function handleReminderRun(): void + { + $providedToken = trim((string) ($_GET['token'] ?? ($_SERVER['HTTP_X_REMINDER_TOKEN'] ?? ''))); + $expectedToken = $this->webPush->cronToken(); + + if ($providedToken === '' || !hash_equals($expectedToken, $providedToken)) { + json_response(['ok' => false, 'message' => 'Ungültiger Reminder-Token.'], 403); + } + + $now = new DateTimeImmutable('now'); + $today = $now->format('Y-m-d'); + $currentTime = $now->format('H:i'); + $processed = 0; + $sentUsers = 0; + $alreadyTracked = 0; + $skipped = 0; + $removed = 0; + + foreach ($this->users->all() as $account) { + $username = (string) ($account['username'] ?? ''); + if ($username === '') { + continue; + } + + $processed++; + $settings = $this->hydrateSettings($this->settings->forUser($username)); + $state = $this->notifications->reminderState($username); + + if (!$this->isReminderDue($settings, $state, $today, $currentTime)) { + $skipped++; + continue; + } + + if ($this->entries->find($username, $today) !== null) { + $alreadyTracked++; + continue; + } + + $result = $this->sendNotificationsForUser($username, [ + 'title' => 'Mood-Board', + 'body' => 'Nimm dir kurz Zeit für deinen heutigen Eintrag.', + 'url' => '/track?date=' . rawurlencode($today), + 'tag' => 'mood-reminder-' . $today, + ]); + + $removed += $result['removed']; + + if ($result['sent'] > 0) { + $sentUsers++; + $this->notifications->saveReminderState($username, [ + 'last_sent_date' => $today, + 'last_sent_at' => date(DATE_ATOM), + ]); + } + } + + json_response([ + 'ok' => true, + 'processed' => $processed, + 'sent_users' => $sentUsers, + 'already_tracked' => $alreadyTracked, + 'skipped' => $skipped, + 'removed_subscriptions' => $removed, + ]); + } + + private function sendNotificationsForUser(string $username, array $message): array + { + $subscriptions = $this->notifications->subscriptionsForUser($username); + $sent = 0; + $removedEndpoints = []; + + foreach ($subscriptions as $subscription) { + try { + $result = $this->webPush->send($subscription, $message); + } catch (RuntimeException) { + $result = [ + 'ok' => false, + 'remove' => false, + ]; + } + + if (!empty($result['ok'])) { + $sent++; + continue; + } + + if (!empty($result['remove'])) { + $endpoint = (string) ($subscription['endpoint'] ?? ''); + if ($endpoint !== '') { + $removedEndpoints[] = $endpoint; + } + } + } + + if ($removedEndpoints !== []) { + $this->notifications->removeInvalidSubscriptions($username, $removedEndpoints); + } + + return [ + 'sent' => $sent, + 'removed' => count($removedEndpoints), + ]; + } + + private function isReminderDue(array $settings, array $state, string $today, string $currentTime): bool + { + if (empty($settings['notifications']['enabled'])) { + return false; + } + + $reminderTime = (string) ($settings['notifications']['time'] ?? ''); + if (!$this->isValidTime($reminderTime)) { + return false; + } + + if ($currentTime < $reminderTime) { + return false; + } + + return (string) ($state['last_sent_date'] ?? '') !== $today; + } } diff --git a/src/Domain/EntryRepository.php b/src/Domain/EntryRepository.php index 08fadc9..f3061c2 100644 --- a/src/Domain/EntryRepository.php +++ b/src/Domain/EntryRepository.php @@ -77,6 +77,10 @@ final class EntryRepository $sportTypes = normalize_sport_type_selection($sportType); } + $walkModeRaw = strtolower((string) ($this->extract('/^- Spaziergang-Modus:\s*(.+)$/mu', $content) ?? 'zeit')); + $walkMode = $walkModeRaw === 'schritte' ? 'steps' : 'time'; + $walkValue = (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0); + $entry = [ 'date' => $this->extract('/^Datum:\s*(\d{4}-\d{2}-\d{2})$/m', $content) ?? $fallbackDate, 'mood' => (int) ($this->extract('/^- Stimmung:\s*(.+)$/m', $content) ?? 5), @@ -87,7 +91,9 @@ final class EntryRepository 'sport_minutes' => (int) ($this->extract('/^- Sport:\s*(.+)$/m', $content) ?? 0), 'sport_type' => $sportTypes[0] ?? '', 'sport_types' => $sportTypes, - 'walk_minutes' => (int) ($this->extract('/^- Spaziergang:\s*(.+)$/m', $content) ?? 0), + 'walk_mode' => $walkMode, + 'walk_minutes' => $walkMode === 'time' ? $walkValue : 0, + 'walk_steps' => $walkMode === 'steps' ? $walkValue : 0, 'note' => $this->extractNote($content), ]; @@ -138,7 +144,8 @@ final class EntryRepository '- Schlafgefühl: ' . $entry['sleep_feeling'], '- Sport: ' . $entry['sport_minutes'], '- Sportarten: ' . implode(', ', $sportTypeValues), - '- Spaziergang: ' . $entry['walk_minutes'], + '- Spaziergang-Modus: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? 'schritte' : 'zeit'), + '- Spaziergang: ' . (($entry['walk_mode'] ?? 'time') === 'steps' ? (int) ($entry['walk_steps'] ?? 0) : (int) ($entry['walk_minutes'] ?? 0)), '', '## Bewertung', '- Punkte: ' . format_points((float) $evaluation['total']) . ' / ' . format_points((float) $evaluation['max_total']), diff --git a/src/Domain/NotificationRepository.php b/src/Domain/NotificationRepository.php new file mode 100644 index 0000000..640a246 --- /dev/null +++ b/src/Domain/NotificationRepository.php @@ -0,0 +1,149 @@ +systemPath = storage_path('system/notifications.json'); + } + + public function systemConfig(): array + { + $config = decode_json_file($this->systemPath, []); + $changed = false; + + if (!isset($config['cron_token']) || !is_string($config['cron_token']) || $config['cron_token'] === '') { + $config['cron_token'] = bin2hex(random_bytes(24)); + $changed = true; + } + + if (!isset($config['subject']) || !is_string($config['subject']) || $config['subject'] === '') { + $host = parse_url(app_origin(), PHP_URL_HOST); + $host = is_string($host) && $host !== '' ? $host : 'localhost'; + $config['subject'] = 'mailto:hello@' . $host; + $changed = true; + } + + if ($changed) { + $this->writeJson($this->systemPath, $config); + } + + return $config; + } + + public function saveVapidKeys(string $publicKey, string $privateKey): void + { + $config = $this->systemConfig(); + $config['vapid_public_key'] = $publicKey; + $config['vapid_private_key'] = $privateKey; + $this->writeJson($this->systemPath, $config); + } + + public function subscriptionsForUser(string $username): array + { + $payload = decode_json_file($this->subscriptionsPath($username), ['subscriptions' => []]); + + return array_values(array_filter($payload['subscriptions'] ?? [], 'is_array')); + } + + public function saveSubscription(string $username, array $subscription): void + { + $endpoint = trim((string) ($subscription['endpoint'] ?? '')); + if ($endpoint === '') { + throw new RuntimeException('Die Subscription ist unvollständig.'); + } + + $subscriptions = $this->subscriptionsForUser($username); + $saved = false; + + foreach ($subscriptions as &$entry) { + if (($entry['endpoint'] ?? '') === $endpoint) { + $entry = array_merge($entry, $subscription, [ + 'updated_at' => date(DATE_ATOM), + ]); + $saved = true; + break; + } + } + unset($entry); + + if (!$saved) { + $subscription['created_at'] = date(DATE_ATOM); + $subscription['updated_at'] = date(DATE_ATOM); + $subscriptions[] = $subscription; + } + + $this->writeJson($this->subscriptionsPath($username), ['subscriptions' => array_values($subscriptions)]); + } + + public function removeSubscription(string $username, string $endpoint): void + { + $endpoint = trim($endpoint); + if ($endpoint === '') { + return; + } + + $subscriptions = array_values(array_filter( + $this->subscriptionsForUser($username), + static fn (array $entry): bool => ($entry['endpoint'] ?? '') !== $endpoint + )); + + $this->writeJson($this->subscriptionsPath($username), ['subscriptions' => $subscriptions]); + } + + public function removeInvalidSubscriptions(string $username, array $endpoints): void + { + foreach ($endpoints as $endpoint) { + if (is_string($endpoint) && $endpoint !== '') { + $this->removeSubscription($username, $endpoint); + } + } + } + + public function reminderState(string $username): array + { + return decode_json_file($this->reminderStatePath($username), []); + } + + public function saveReminderState(string $username, array $state): void + { + $this->writeJson($this->reminderStatePath($username), $state); + } + + public function subscriptionCount(string $username): int + { + return count($this->subscriptionsForUser($username)); + } + + private function subscriptionsPath(string $username): string + { + return storage_path('users/' . normalize_username($username) . '/push-subscriptions.json'); + } + + private function reminderStatePath(string $username): string + { + return storage_path('users/' . normalize_username($username) . '/notification-state.json'); + } + + private function writeJson(string $path, array $payload): void + { + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $bytes = file_put_contents( + $path, + json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + LOCK_EX + ); + + if ($bytes === false) { + throw new RuntimeException('Die Benachrichtigungsdaten konnten nicht gespeichert werden.'); + } + } +} diff --git a/src/Domain/ScoringService.php b/src/Domain/ScoringService.php index 035538e..284dd63 100644 --- a/src/Domain/ScoringService.php +++ b/src/Domain/ScoringService.php @@ -18,7 +18,9 @@ final class ScoringService 'sport_minutes' => max(0, min(1440, (int) ($input['sport_minutes'] ?? 0))), 'sport_type' => $sportTypes[0] ?? '', 'sport_types' => $sportTypes, + 'walk_mode' => $this->normalizeWalkMode((string) ($input['walk_mode'] ?? ((isset($input['walk_steps']) && !isset($input['walk_minutes'])) ? 'steps' : 'time'))), 'walk_minutes' => max(0, min(1440, (int) ($input['walk_minutes'] ?? 0))), + 'walk_steps' => max(0, min(50000, (int) ($input['walk_steps'] ?? 0))), 'note' => trim((string) ($input['note'] ?? '')), ]; } @@ -40,7 +42,7 @@ final class ScoringService 'sleep_feeling' => $entry['sleep_feeling'] * (float) $scoring['sleep_feeling_multiplier'], 'sport_minutes' => $this->bandPoints((int) $entry['sport_minutes'], $scoring['sport_bands']), 'sport_bonus' => $sportBonus, - 'walk_minutes' => $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands']), + 'walk_minutes' => $this->walkPoints($entry, $settings), 'note' => $entry['note'] === '' ? 0 : (float) $scoring['journal_points'], ]; @@ -53,7 +55,7 @@ final class ScoringService (5 * (float) $scoring['sleep_feeling_multiplier']) + $this->maxBandPoints($scoring['sport_bands']) + $this->maxSportBonusPoints($settings) + - $this->maxBandPoints($scoring['walk_bands']) + + $this->maxWalkPoints($entry, $settings) + (float) $scoring['journal_points'], 1 ); @@ -146,6 +148,72 @@ final class ScoringService return $max; } + private function walkPoints(array $entry, array $settings): float + { + $entry = $this->normalize($entry); + $scoring = $settings['scoring'] ?? []; + + if (($entry['walk_mode'] ?? 'time') === 'steps') { + return $this->stepTargetPoints((int) $entry['walk_steps'], $scoring['walk_step_targets'] ?? []); + } + + return $this->bandPoints((int) $entry['walk_minutes'], $scoring['walk_bands'] ?? []); + } + + private function maxWalkPoints(array $entry, array $settings): float + { + $scoring = $settings['scoring'] ?? []; + + if (($entry['walk_mode'] ?? 'time') === 'steps') { + $max = 0.0; + foreach ($scoring['walk_step_targets'] ?? [] as $target) { + $max = max($max, (float) ($target['points'] ?? 0)); + } + + return $max; + } + + return $this->maxBandPoints($scoring['walk_bands'] ?? []); + } + + private function stepTargetPoints(int $steps, array $targets): float + { + if ($targets === []) { + return 0.0; + } + + usort($targets, static fn (array $a, array $b): int => ((int) ($a['steps'] ?? 0)) <=> ((int) ($b['steps'] ?? 0))); + + if ($steps <= (int) ($targets[0]['steps'] ?? 0)) { + return (float) ($targets[0]['points'] ?? 0); + } + + $lastIndex = count($targets) - 1; + if ($steps >= (int) ($targets[$lastIndex]['steps'] ?? 0)) { + return (float) ($targets[$lastIndex]['points'] ?? 0); + } + + for ($index = 1; $index < count($targets); $index++) { + $previous = $targets[$index - 1]; + $current = $targets[$index]; + $previousSteps = (int) ($previous['steps'] ?? 0); + $currentSteps = (int) ($current['steps'] ?? 0); + + if ($steps > $currentSteps) { + continue; + } + + $range = max(1, $currentSteps - $previousSteps); + $ratio = ($steps - $previousSteps) / $range; + $previousPoints = (float) ($previous['points'] ?? 0); + $currentPoints = (float) ($current['points'] ?? 0); + + return round($previousPoints + (($currentPoints - $previousPoints) * $ratio), 1); + } + + return 0.0; + } + private function maxSportBonusPoints(array $settings): float { $max = 0.0; @@ -280,4 +348,9 @@ final class ScoringService default => 'radiant', }; } + + private function normalizeWalkMode(string $mode): string + { + return $mode === 'steps' ? 'steps' : 'time'; + } } diff --git a/src/Support/Defaults.php b/src/Support/Defaults.php index e95b462..66b5745 100644 --- a/src/Support/Defaults.php +++ b/src/Support/Defaults.php @@ -16,6 +16,9 @@ final class Defaults 5 => 'sehr ausgeschlafen', ], ], + 'walk' => [ + 'mode' => 'time', + ], 'sport_types' => [ [ 'id' => 'running', @@ -135,6 +138,16 @@ final class Defaults ['min' => 16, 'max' => 40, 'points' => 5], ['min' => 41, 'max' => 10000, 'points' => 7], ], + 'walk_step_targets' => [ + ['steps' => 0, 'points' => 0], + ['steps' => 3000, 'points' => 0], + ['steps' => 5000, 'points' => 2], + ['steps' => 7500, 'points' => 5], + ['steps' => 10000, 'points' => 7], + ['steps' => 12500, 'points' => 6], + ['steps' => 15000, 'points' => 4], + ['steps' => 20000, 'points' => 0], + ], 'journal_points' => 2, ], 'ratings' => [ @@ -156,6 +169,10 @@ final class Defaults 'cap_label' => 'schwerer Tag', ], ], + 'notifications' => [ + 'enabled' => false, + 'time' => '20:30', + ], ]; } } diff --git a/src/Support/WebPushService.php b/src/Support/WebPushService.php new file mode 100644 index 0000000..59c31d1 --- /dev/null +++ b/src/Support/WebPushService.php @@ -0,0 +1,328 @@ +notifications = $notifications; + } + + public function isAvailable(): bool + { + return function_exists('openssl_pkey_new') + && function_exists('openssl_sign') + && function_exists('openssl_encrypt') + && function_exists('openssl_pkey_derive') + && function_exists('curl_init'); + } + + public function publicKey(): ?string + { + $keys = $this->keys(); + + return $keys['public'] ?? null; + } + + public function cronToken(): string + { + return (string) ($this->notifications->systemConfig()['cron_token'] ?? ''); + } + + public function send(array $subscription, array $message): array + { + if (!$this->isAvailable()) { + throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.'); + } + + $endpoint = trim((string) ($subscription['endpoint'] ?? '')); + $p256dh = trim((string) ($subscription['keys']['p256dh'] ?? '')); + $auth = trim((string) ($subscription['keys']['auth'] ?? '')); + + if ($endpoint === '' || $p256dh === '' || $auth === '') { + throw new RuntimeException('Die Push-Subscription ist unvollständig.'); + } + + $payload = json_encode([ + 'title' => (string) ($message['title'] ?? 'Mood-Board'), + 'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'), + 'icon' => '/assets/branding/logo-mark.svg', + 'badge' => '/assets/branding/favicon.svg', + 'url' => (string) ($message['url'] ?? '/track'), + 'tag' => (string) ($message['tag'] ?? 'mood-reminder'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if (!is_string($payload)) { + throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.'); + } + + $encrypted = $this->encrypt($payload, $p256dh, $auth); + $audience = $this->audienceForEndpoint($endpoint); + $authorization = $this->authorizationHeader($audience); + + $headers = [ + 'TTL: 3600', + 'Urgency: normal', + 'Content-Encoding: aes128gcm', + 'Content-Type: application/octet-stream', + 'Authorization: ' . $authorization['header'], + 'Crypto-Key: p256ecdsa=' . $authorization['public'], + ]; + + $handle = curl_init($endpoint); + if ($handle === false) { + throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.'); + } + + curl_setopt_array($handle, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $encrypted['body'], + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => false, + CURLOPT_TIMEOUT => 12, + ]); + + $responseBody = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $error = curl_error($handle); + curl_close($handle); + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'remove' => in_array($status, [404, 410], true), + 'error' => $error !== '' ? $error : null, + 'response' => is_string($responseBody) ? $responseBody : null, + ]; + } + + private function keys(): array + { + $config = $this->notifications->systemConfig(); + $public = trim((string) ($config['vapid_public_key'] ?? '')); + $private = trim((string) ($config['vapid_private_key'] ?? '')); + + if ($public !== '' && $private !== '') { + return ['public' => $public, 'private' => $private]; + } + + if (!$this->isAvailable()) { + return ['public' => null, 'private' => null]; + } + + $resource = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + + if ($resource === false) { + throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.'); + } + + $details = openssl_pkey_get_details($resource); + if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) { + throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.'); + } + + $publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y']; + $privateKey = $details['ec']['d']; + + $encodedPublic = base64url_encode($publicKey); + $encodedPrivate = base64url_encode($privateKey); + + $this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate); + + return ['public' => $encodedPublic, 'private' => $encodedPrivate]; + } + + private function encrypt(string $payload, string $userPublicKey, string $authSecret): array + { + $salt = random_bytes(16); + $userPublicRaw = base64url_decode($userPublicKey); + $authRaw = base64url_decode($authSecret); + + $localKey = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($localKey === false) { + throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.'); + } + + $localDetails = openssl_pkey_get_details($localKey); + if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) { + throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.'); + } + + $localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y']; + $userPem = $this->publicKeyPemFromRaw($userPublicRaw); + $sharedSecret = openssl_pkey_derive($userPem, $localKey, 32); + + if (!is_string($sharedSecret) || $sharedSecret === '') { + throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.'); + } + + $context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw; + $keyMaterial = $this->hkdfExpand( + $this->hkdfExtract($authRaw, $sharedSecret), + $context, + 32 + ); + + $contentPrk = $this->hkdfExtract($salt, $keyMaterial); + $contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16); + $nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12); + + $recordSize = 4096; + $plaintext = $payload . "\x02"; + $tag = ''; + $ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag); + + if (!is_string($ciphertext)) { + throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.'); + } + + $body = $salt + . pack('N', $recordSize) + . chr(strlen($localPublicRaw)) + . $localPublicRaw + . $ciphertext + . $tag; + + return [ + 'body' => $body, + 'local_public' => $localPublicRaw, + ]; + } + + private function authorizationHeader(string $audience): array + { + $keys = $this->keys(); + $header = base64url_encode((string) json_encode([ + 'typ' => 'JWT', + 'alg' => 'ES256', + ], JSON_UNESCAPED_SLASHES)); + $payload = base64url_encode((string) json_encode([ + 'aud' => $audience, + 'exp' => time() + 3600, + 'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'), + ], JSON_UNESCAPED_SLASHES)); + + $signingInput = $header . '.' . $payload; + $signatureDer = ''; + $privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private'])); + + if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) { + throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.'); + } + + $jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64)); + + return [ + 'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'], + 'public' => (string) $keys['public'], + ]; + } + + private function audienceForEndpoint(string $endpoint): string + { + $parts = parse_url($endpoint); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new RuntimeException('Der Push-Endpunkt ist ungültig.'); + } + + return $parts['scheme'] . '://' . $parts['host']; + } + + private function hkdfExtract(string $salt, string $ikm): string + { + return hash_hmac('sha256', $ikm, $salt, true); + } + + private function hkdfExpand(string $prk, string $info, int $length): string + { + $output = ''; + $block = ''; + $counter = 1; + + while (strlen($output) < $length) { + $block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true); + $output .= $block; + $counter++; + } + + return substr($output, 0, $length); + } + + private function publicKeyPemFromRaw(string $raw): string + { + $der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw; + + return "-----BEGIN PUBLIC KEY-----\n" + . chunk_split(base64_encode((string) $der), 64, "\n") + . "-----END PUBLIC KEY-----\n"; + } + + private function privateKeyPemFromRaw(string $raw): string + { + $der = hex2bin('30770201010420') + . $raw + . hex2bin('A00A06082A8648CE3D030107A14403420004') + . substr(base64url_decode((string) $this->keys()['public']), 1); + + return "-----BEGIN EC PRIVATE KEY-----\n" + . chunk_split(base64_encode((string) $der), 64, "\n") + . "-----END EC PRIVATE KEY-----\n"; + } + + private function derSignatureToJose(string $der, int $partLength): string + { + $offset = 0; + if (ord($der[$offset]) !== 0x30) { + throw new RuntimeException('Ungültige DER-Signatur.'); + } + $offset++; + $this->readAsnLength($der, $offset); + + if (ord($der[$offset]) !== 0x02) { + throw new RuntimeException('Ungültiger DER-R-Teil.'); + } + $offset++; + $rLength = $this->readAsnLength($der, $offset); + $r = substr($der, $offset, $rLength); + $offset += $rLength; + + if (ord($der[$offset]) !== 0x02) { + throw new RuntimeException('Ungültiger DER-S-Teil.'); + } + $offset++; + $sLength = $this->readAsnLength($der, $offset); + $s = substr($der, $offset, $sLength); + + return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT) + . str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT); + } + + private function readAsnLength(string $der, int &$offset): int + { + $length = ord($der[$offset]); + $offset++; + + if (($length & 0x80) === 0) { + return $length; + } + + $numberOfBytes = $length & 0x7F; + $length = 0; + for ($index = 0; $index < $numberOfBytes; $index++) { + $length = ($length << 8) | ord($der[$offset]); + $offset++; + } + + return $length; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index b205b46..7b38e2e 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -6,10 +6,12 @@ require __DIR__ . '/helpers.php'; require __DIR__ . '/Support/Defaults.php'; require __DIR__ . '/Support/Auth.php'; require __DIR__ . '/Support/View.php'; +require __DIR__ . '/Support/WebPushService.php'; require __DIR__ . '/Domain/UserRepository.php'; require __DIR__ . '/Domain/SettingsRepository.php'; require __DIR__ . '/Domain/EntryRepository.php'; require __DIR__ . '/Domain/LoginThrottle.php'; +require __DIR__ . '/Domain/NotificationRepository.php'; require __DIR__ . '/Domain/ScoringService.php'; require __DIR__ . '/App.php'; diff --git a/src/helpers.php b/src/helpers.php index 69044cb..43206db 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -29,6 +29,14 @@ function redirect(string $path): never exit; } +function json_response(array $payload, int $status = 200): never +{ + http_response_code($status); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + function request_path(): string { $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); @@ -80,6 +88,17 @@ function verify_csrf(?string $token): bool return hash_equals(csrf_token(), $token); } +function verify_request_csrf(): bool +{ + $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + + if (is_string($headerToken) && $headerToken !== '') { + return verify_csrf($headerToken); + } + + return verify_csrf($_POST['_token'] ?? null); +} + function is_active_path(string $path): bool { return request_path() === $path; @@ -117,6 +136,18 @@ function decode_json_file(string $path, array $fallback = []): array return is_array($decoded) ? $decoded : $fallback; } +function request_json_body(): array +{ + $raw = file_get_contents('php://input'); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : []; +} + function encode_payload(array $payload): string { return base64_encode((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @@ -188,6 +219,14 @@ function request_is_secure(): bool ); } +function app_origin(): string +{ + $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $scheme = request_is_secure() ? 'https' : 'http'; + + return $scheme . '://' . $host; +} + function remember_me_lifetime(): int { return 60 * 60 * 24 * 30; @@ -257,6 +296,62 @@ function sport_location_label(?string $value): string return $options[$value] ?? ''; } +function walk_mode_options(): array +{ + return [ + 'time' => 'Spaziergang nach Zeit', + 'steps' => 'Spaziergang nach Schritten', + ]; +} + +function walk_mode_label(string $mode): string +{ + return walk_mode_options()[$mode] ?? walk_mode_options()['time']; +} + +function format_walk_value(array $entry): string +{ + $mode = (string) ($entry['walk_mode'] ?? 'time'); + + if ($mode === 'steps') { + return number_format((int) ($entry['walk_steps'] ?? 0), 0, ',', '.') . ' Schritte'; + } + + return (string) ((int) ($entry['walk_minutes'] ?? 0)) . ' min'; +} + +function walk_chart_value(array $entry): int +{ + $mode = (string) ($entry['walk_mode'] ?? 'time'); + + if ($mode === 'steps') { + return max(0, (int) round(((int) ($entry['walk_steps'] ?? 0)) / 200)); + } + + return max(0, (int) ($entry['walk_minutes'] ?? 0)); +} + +function base64url_encode(string $data): string +{ + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); +} + +function base64url_decode(string $data): string +{ + $padding = strlen($data) % 4; + if ($padding > 0) { + $data .= str_repeat('=', 4 - $padding); + } + + $decoded = base64_decode(strtr($data, '-_', '+/'), true); + + if ($decoded === false) { + throw new RuntimeException('Ungültige Base64url-Daten.'); + } + + return $decoded; +} + function normalize_sport_type_id(string $value): string { $value = trim(strtr($value, [ diff --git a/templates/layout.php b/templates/layout.php index c42ebcd..3e2a791 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -6,7 +6,7 @@ $brandSubtitle = match ($page) { 'dashboard' => 'Statistiken und Verlauf', 'track' => 'Tag erfassen und bewerten', 'archive' => 'Rückblick auf vergangene Tage', - 'options' => 'Logik, Sicherheit und Accounts', + 'options' => 'Logik, Erinnerungen, Sicherheit und Accounts', 'login' => 'Geschützter Zugang', 'setup' => 'Erstkonfiguration', default => 'Stimmungstracker', @@ -19,13 +19,22 @@ $brandSubtitle = match ($page) { + + + + + + + <?= e($pageTitle) ?> · Mood + + -> +>
@@ -101,6 +110,27 @@ $brandSubtitle = match ($page) { + + + +
diff --git a/templates/pages/archive.php b/templates/pages/archive.php index d973503..304bf46 100644 --- a/templates/pages/archive.php +++ b/templates/pages/archive.php @@ -75,7 +75,7 @@
Sportbonus
-
Spaziergang
min
+
Spaziergang
diff --git a/templates/pages/dashboard.php b/templates/pages/dashboard.php index a913738..9e5449b 100644 --- a/templates/pages/dashboard.php +++ b/templates/pages/dashboard.php @@ -79,7 +79,7 @@

Aktivität

Sport und Spaziergang

- Minuten pro Tag + Aktivität pro Tag
diff --git a/templates/pages/options.php b/templates/pages/options.php index d82d901..5856921 100644 --- a/templates/pages/options.php +++ b/templates/pages/options.php @@ -49,16 +49,39 @@
-

Spaziergang-Bänder

-
- $band): ?> -
- - - -
- +
+
+

Spaziergang

+

Du kannst Spaziergänge pro Account entweder über Zeit oder über Schritte bewerten lassen.

+
+ + + + +
+
Schritte mit Bestwert bei 10.000
+

Bei Schritten liegt der beste Wert bei 10.000. Darunter steigt die Punktzahl schrittweise an, darüber fällt sie wieder sanft ab.

+

Aktueller Verlauf: 0 / 3.000 / 5.000 / 7.500 / 10.000 / 12.500 / 15.000 / 20.000 Schritte

+
+ +
+ $band): ?> +
+ + + +
+ +
+
@@ -217,6 +240,55 @@
+
+
+
+

Erinnerungen

+

Diese Erinnerungen gelten nur für deinen Account. Auf dem iPhone funktioniert Push nach dem Hinzufügen zum Home-Bildschirm.

+
+ Gerät +
+ +
+ + + +
+ +
+
+
Push auf diesem Gerät
+

+ + Installiere die App auf Wunsch als PWA und aktiviere dann Push direkt auf diesem Gerät. + + Push ist auf diesem Server gerade noch nicht verfügbar. + +

+
+ +
+ + + +
+
+ +
+ +
+
+

Bewertungsskala

Diese Score-Grenzen und Schutzregeln gelten ebenfalls nur für deinen eigenen Account.

diff --git a/templates/pages/track.php b/templates/pages/track.php index 2512ee1..980d311 100644 --- a/templates/pages/track.php +++ b/templates/pages/track.php @@ -14,6 +14,8 @@
+ +