Add PWA reminders and flexible walk scoring
This commit is contained in:
+312
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class NotificationRepository
|
||||
{
|
||||
private string $systemPath;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class WebPushService
|
||||
{
|
||||
private NotificationRepository $notifications;
|
||||
|
||||
public function __construct(NotificationRepository $notifications)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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, [
|
||||
|
||||
Reference in New Issue
Block a user