diff --git a/.env.example b/.env.example index e3b1b4fa..aded6a0f 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,22 @@ LOG_LEVEL=debug # gemeldet (Fallback: LEGAL_CONTACT_EMAIL). In Prod echten Mailer voraussetzen. MONITORING_ALERTS_ENABLED=true MONITORING_ALERT_EMAIL= +# Zweiter Alarmweg per ntfy (Monitoring-Plan M3) — ohne URL/Topic aus. +MONITORING_NTFY_URL= +MONITORING_NTFY_TOPIC= +MONITORING_NTFY_TOKEN= +MONITORING_NTFY_PRIORITY=4 +# Tiefer Health-Check /health für den externen Monitor (Bearer oder ?token=). +# Ohne Token gibt es den Endpunkt nicht. +MONITORING_HEALTH_TOKEN= + +# Fehler an GlitchTip (selbst gehostet, Sentry-Protokoll; Monitoring-Plan +# 1.1/1.2). Ohne DSN passiert nichts. SENTRY_RELEASE bei jedem Deploy auf +# den Git-Hash setzen, dann ordnet GlitchTip Fehler der Version zu. +SENTRY_LARAVEL_DSN= +SENTRY_BROWSER_DSN= +SENTRY_ENVIRONMENT= +SENTRY_RELEASE= # --------------------------------------------------------------------------- # Multi-Domain-Setup (config/domains.php) @@ -254,6 +270,10 @@ BILLING_DEPUBLICATION_PRICE_CENTS=3500 # config/domains.php). Erst beim Go-Live der Webseiten auf true setzen — # die Datenschutzerklärung (Abschnitt 5) beschreibt den Einsatz bereits. UMAMI_ENABLED=false +# Kundenbereich und Zuordnung zur User-ID getrennt (Monitoring-Plan M6) — +# erst nach Ergänzung der Datenschutzerklärung einschalten. +UMAMI_PORTAL_ENABLED=false +UMAMI_IDENTIFY_USERS=false # Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB, # Meldeverfahren, Richtlinien) — auf allen drei Domains identisch. diff --git a/.gitignore b/.gitignore index 931e9850..4081bf06 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,8 @@ docs/legal/ # Schlüsseldateien (Search-Console-Service-Konto), nie ins Repo config/files/ + +# Release-Kennung für GlitchTip, entsteht beim Deploy (dev/betrieb/monitoring-plan.md) +/RELEASE +/deploy-files.txt +/deploy-*.tgz diff --git a/app/Console/Commands/MonitoringHeartbeat.php b/app/Console/Commands/MonitoringHeartbeat.php new file mode 100644 index 00000000..6bb7af46 --- /dev/null +++ b/app/Console/Commands/MonitoringHeartbeat.php @@ -0,0 +1,25 @@ +recordHeartbeat(); + + return self::SUCCESS; + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index a317a72a..2cdb356e 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -37,6 +37,13 @@ enum ActivityType: string /** Korrekturweg Bildnachweis/Kennzeichnung (Bildkennzeichnung E.6). */ case ImageCorrection = 'image_correction'; + /** Fehlgeschlagene Anmeldung, Sperre, falscher 2FA-Code (Monitoring-Plan 1.7). */ + case LoginFailed = 'login_failed'; + + case LoginLockout = 'login_lockout'; + + case TwoFactorFailed = 'two_factor_failed'; + public function label(): string { return match ($this) { @@ -63,6 +70,9 @@ enum ActivityType: string self::ManualInvoice => 'Rechnung (MAN)', self::EvidenceAccess => 'Beweiszugriff', self::ImageCorrection => 'Bildkorrektur', + self::LoginFailed => 'Anmeldung fehlgeschlagen', + self::LoginLockout => 'Anmeldesperre', + self::TwoFactorFailed => '2FA fehlgeschlagen', }; } @@ -90,6 +100,8 @@ enum ActivityType: string self::ImageCorrection => 'blue', self::MailSuppressed => 'orange', self::ManualInvoice => 'amber', + self::LoginFailed, self::TwoFactorFailed => 'orange', + self::LoginLockout => 'red', }; } } diff --git a/app/Http/Controllers/HealthController.php b/app/Http/Controllers/HealthController.php new file mode 100644 index 00000000..d353057e --- /dev/null +++ b/app/Http/Controllers/HealthController.php @@ -0,0 +1,39 @@ +bearerToken() ?? $request->query('token', '')); + + abort_if($token === '' || ! hash_equals($token, $given), 404); + + $checks = $probe->checks(); + $status = HealthProbe::worstStatus($checks); + + return response()->json([ + 'status' => $status, + 'version' => config('app.version'), + 'checked_at' => now()->toIso8601String(), + 'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [ + $check['key'] => ['status' => $check['status'], 'detail' => $check['detail']], + ]), + ], $status === HealthProbe::StatusCritical ? 503 : 200) + ->header('Cache-Control', 'no-store'); + } +} diff --git a/app/Http/Middleware/BasicAuthMiddleware.php b/app/Http/Middleware/BasicAuthMiddleware.php index ec3674de..e62a8a76 100644 --- a/app/Http/Middleware/BasicAuthMiddleware.php +++ b/app/Http/Middleware/BasicAuthMiddleware.php @@ -50,6 +50,13 @@ class BasicAuthMiddleware return $next($request); } + // Skip Basic Auth für den Health-Endpunkt — er hat ein eigenes + // Bearer-Token, und ein Monitor kann nicht beides im + // Authorization-Header schicken (Monitoring-Plan 1.5). + if ($request->is('health')) { + return $next($request); + } + // Zugangsdaten aus der Config (gespeist aus .env). Ist nichts // konfiguriert, wird der Gate nicht erzwungen. $user = config('auth.basic.user'); diff --git a/app/Http/Middleware/ReportErrorSignals.php b/app/Http/Middleware/ReportErrorSignals.php new file mode 100644 index 00000000..b15713d1 --- /dev/null +++ b/app/Http/Middleware/ReportErrorSignals.php @@ -0,0 +1,96 @@ +signalFor($request, $response->getStatusCode()); + + if ($signal !== null) { + $location = $this->locationOf($request); + $context = [ + 'status' => $response->getStatusCode(), + 'method' => $request->method(), + 'path' => '/'.ltrim($request->path(), '/'), + 'referer' => $this->refererPath($request), + 'livewire' => $request->hasHeader('X-Livewire'), + ]; + + defer(fn () => $this->errorSignal->report($signal, $location, $context)); + } + + return $response; + } + + private function signalFor(Request $request, int $status): ?string + { + return match (true) { + $status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink, + $status === 403 && $request->user() !== null => ErrorSignal::AccessDenied, + $status === 419 => ErrorSignal::PageExpired, + $status === 429 && $request->user() !== null => ErrorSignal::Throttled, + default => null, + }; + } + + /** + * Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort + * ist dort die Seite, auf der der Nutzer stand. + */ + private function locationOf(Request $request): string + { + if ($request->hasHeader('X-Livewire')) { + return 'livewire '.($this->refererPath($request) ?? '/'); + } + + $routeName = $request->route()?->getName(); + + return $routeName !== null + ? $routeName + : '/'.ltrim($request->path(), '/'); + } + + private function hasInternalReferer(Request $request): bool + { + $referer = $request->headers->get('referer'); + + return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost(); + } + + private function refererPath(Request $request): ?string + { + $referer = $request->headers->get('referer'); + + if (! is_string($referer) || $referer === '') { + return null; + } + + $path = parse_url($referer, PHP_URL_PATH); + + return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null; + } +} diff --git a/app/Listeners/RecordAuthActivity.php b/app/Listeners/RecordAuthActivity.php index 93621d5c..f407b023 100644 --- a/app/Listeners/RecordAuthActivity.php +++ b/app/Listeners/RecordAuthActivity.php @@ -9,11 +9,16 @@ use App\Models\User; use App\Services\ActivityLog; use App\Services\Auth\LoginCompletion; use App\Services\Conversion\ConversionRecorder; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; +use Illuminate\Http\Request; +use Laravel\Fortify\Events\TwoFactorAuthenticationFailed; /** - * Protokolliert Registrierungen und Logins im Aktivitäts-Verlauf. + * Protokolliert Registrierungen, Logins und gescheiterte Anmeldungen im + * Aktivitäts-Verlauf. * * Der Anmeldeweg kommt als Request-Attribut `auth_method` von der jeweils * einloggenden Stelle (Magic-Link-/Google-Controller, Impersonation), nach @@ -28,6 +33,12 @@ class RecordAuthActivity { public const AUTH_METHOD_ATTRIBUTE = 'auth_method'; + private const FAILURE_LABELS = [ + 'unknown_account' => 'unbekannte Adresse', + 'no_password' => 'Konto ohne Passwort (Magic Link/Google)', + 'wrong_password' => 'falsches Passwort', + ]; + private const METHOD_LABELS = [ 'password' => 'Passwort', 'magic-link' => 'Magic-Link', @@ -84,4 +95,69 @@ class RecordAuthActivity meta: ['method' => $method], ); } + + /** + * Gescheiterte Passwort-Anmeldung (Monitoring-Plan 1.7). Aus den + * Zugangsdaten wird nur die Adresse gelesen — nie das Passwort. Der + * Grund unterscheidet Angriff/Tippfehler von einem Bedienproblem: Ein + * Konto ohne Passwort heißt, der Kunde kennt den Magic-Link-Weg nicht. + */ + public function recordFailedLogin(Failed $event): void + { + $user = $event->user instanceof User ? $event->user : null; + $reason = match (true) { + $user === null => 'unknown_account', + $user->password === null => 'no_password', + default => 'wrong_password', + }; + + $this->activityLog->record( + ActivityType::LoginFailed, + $user !== null + ? sprintf('Anmeldung für %s (%s) fehlgeschlagen — %s.', $user->name, $user->email, self::FAILURE_LABELS[$reason]) + : 'Anmeldung mit unbekannter Adresse fehlgeschlagen.', + user: $user, + meta: ['reason' => $reason, 'ip_hash' => self::ipHash(request())], + actorLabel: $user->name ?? 'unbekannt', + ); + } + + public function recordLockout(Lockout $event): void + { + $email = (string) $event->request->input('email', ''); + $user = $email !== '' ? User::query()->where('email', $email)->first() : null; + + $this->activityLog->record( + ActivityType::LoginLockout, + $user !== null + ? sprintf('Anmeldung für %s (%s) nach zu vielen Fehlversuchen gesperrt.', $user->name, $user->email) + : 'Anmeldung nach zu vielen Fehlversuchen gesperrt (unbekannte Adresse).', + user: $user, + meta: ['ip_hash' => self::ipHash($event->request)], + actorLabel: $user->name ?? 'unbekannt', + ); + } + + public function recordTwoFactorFailed(TwoFactorAuthenticationFailed $event): void + { + $user = $event->user; + + $this->activityLog->record( + ActivityType::TwoFactorFailed, + sprintf('%s (%s) hat einen falschen 2FA- bzw. Wiederherstellungs-Code eingegeben.', $user->name, $user->email), + user: $user, + meta: ['ip_hash' => self::ipHash(request())], + ); + } + + /** + * Gekürzter HMAC der IP (M9): erkennt „dieselbe Quelle“ über mehrere + * Versuche, ohne die Adresse selbst zu speichern. + */ + public static function ipHash(Request $request): ?string + { + $ip = $request->ip(); + + return $ip !== null ? substr(hash_hmac('sha256', $ip, (string) config('app.key')), 0, 12) : null; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d8bb58d..4f097013 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -43,6 +43,8 @@ use App\Services\Newsletter\NullNewsletterSyncClient; use App\Services\PressRelease\PressReleaseService; use App\Support\OperationalAlert; use App\Support\WebPageCache; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Verified; @@ -51,6 +53,7 @@ use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Events\QueryExecuted; +use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Mail\Events\MessageSending; use Illuminate\Mail\Events\MessageSent; use Illuminate\Notifications\Messages\MailMessage; @@ -62,6 +65,7 @@ use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; use Laravel\Cashier\Cashier; +use Laravel\Fortify\Events\TwoFactorAuthenticationFailed; use Livewire\Livewire; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -99,6 +103,9 @@ class AppServiceProvider extends ServiceProvider { Model::preventLazyLoading(app()->isLocal()); + // Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION). + AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('app.version') ?? '—']); + // Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt. // Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst // dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer @@ -166,6 +173,9 @@ class AppServiceProvider extends ServiceProvider // Login; Formular-Eingänge laufen über den ActivityLogObserver unten. Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']); Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']); + Event::listen(Failed::class, [RecordAuthActivity::class, 'recordFailedLogin']); + Event::listen(Lockout::class, [RecordAuthActivity::class, 'recordLockout']); + Event::listen(TwoFactorAuthenticationFailed::class, [RecordAuthActivity::class, 'recordTwoFactorFailed']); // Anmeldezeitpunkt in der Session — Grundlage für das Beenden fremder // Sessions nach einer Kontoübernahme (Audit K-04, SessionRevocation). diff --git a/app/Services/Admin/AdminSystemHealth.php b/app/Services/Admin/AdminSystemHealth.php index 4c6653f6..684b81a1 100644 --- a/app/Services/Admin/AdminSystemHealth.php +++ b/app/Services/Admin/AdminSystemHealth.php @@ -8,6 +8,7 @@ use App\Models\PressReleaseAttachment; use App\Models\PressReleaseImage; use App\Services\Archive\ArchivePendingQueue; use App\Services\Archive\EvidenceArchive; +use App\Services\Monitoring\HealthProbe; use App\Services\PressRelease\Images\PressReleaseImageRemover; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -160,6 +161,12 @@ class AdminSystemHealth 'detail' => $this->queueWorkerDetail($queue), ]; + // Scheduler-Herzschlag und Plattenplatz — dieselben Prüfungen, die + // der externe Monitor über /health abfragt (Monitoring-Plan 1.4/1.5). + $probe = app(HealthProbe::class); + $checks[] = $probe->scheduler(); + $checks[] = $probe->disk(); + // Neue Permissions ausgerollt? $expected = PermissionCatalog::adminOnly(); $existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all(); diff --git a/app/Services/Monitoring/HealthProbe.php b/app/Services/Monitoring/HealthProbe.php new file mode 100644 index 00000000..53319321 --- /dev/null +++ b/app/Services/Monitoring/HealthProbe.php @@ -0,0 +1,193 @@ +toIso8601String()); + } + + public function lastHeartbeat(): ?Carbon + { + $value = AppSetting::getValueFresh(self::HeartbeatKey); + + return is_string($value) ? Carbon::parse($value) : null; + } + + /** + * @return list + */ + public function checks(): array + { + return [ + $this->database(), + $this->cache(), + $this->queue(), + $this->scheduler(), + $this->disk(), + ]; + } + + /** + * @param list $checks + */ + public static function worstStatus(array $checks): string + { + $statuses = array_column($checks, 'status'); + + return match (true) { + in_array(self::StatusCritical, $statuses, true) => self::StatusCritical, + in_array(self::StatusWarn, $statuses, true) => self::StatusWarn, + default => self::StatusOk, + }; + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + public function scheduler(): array + { + $lastBeat = $this->lastHeartbeat(); + $warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes'); + $criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes'); + $minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null; + + return $this->check( + 'scheduler', + 'Scheduler', + match (true) { + $minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical, + $minutesAgo >= $warnMinutes => self::StatusWarn, + default => self::StatusOk, + }, + $lastBeat === null + ? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?' + : 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).', + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + public function disk(): array + { + $path = storage_path(); + $free = @disk_free_space($path); + $total = @disk_total_space($path); + + if ($free === false || $total === false || $total <= 0) { + return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.'); + } + + $freePercent = round($free / $total * 100, 1); + $minPercent = (float) config('monitoring.health.disk_min_free_percent'); + + return $this->check( + 'disk', + 'Plattenplatz', + match (true) { + $freePercent < $minPercent => self::StatusCritical, + $freePercent < $minPercent * 2 => self::StatusWarn, + default => self::StatusOk, + }, + sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)), + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function database(): array + { + try { + DB::select('select 1'); + + return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.'); + } catch (Throwable $exception) { + return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120)); + } + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function cache(): array + { + try { + $value = Str::random(8); + Cache::put('monitoring:health-probe', $value, 60); + + return Cache::get('monitoring:health-probe') === $value + ? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.') + : $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.'); + } catch (Throwable $exception) { + return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120)); + } + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function queue(): array + { + if (config('queue.default') !== 'database') { + return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.'); + } + + try { + $now = now()->getTimestamp(); + $oldestAvailable = DB::table('jobs') + ->whereNull('reserved_at') + ->where('available_at', '<=', $now) + ->min('available_at'); + } catch (Throwable $exception) { + return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120)); + } + + $waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0; + $criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds'); + + return $this->check( + 'queue', + 'Queue', + $waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk, + $oldestAvailable === null + ? 'Kein wartender Job.' + : 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''), + ); + } + + /** + * @return array{key: string, label: string, status: string, detail: string} + */ + private function check(string $key, string $label, string $status, string $detail): array + { + return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail]; + } +} diff --git a/app/Support/Monitoring/ErrorReportScrubber.php b/app/Support/Monitoring/ErrorReportScrubber.php new file mode 100644 index 00000000..12cb1c5b --- /dev/null +++ b/app/Support/Monitoring/ErrorReportScrubber.php @@ -0,0 +1,158 @@ + + */ + public const AllowedQueryParameters = ['src', 'pr', 'ref', 'page', 'q', 'tab', 'edition']; + + private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i'; + + /** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */ + private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#'; + + public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event + { + $userId = self::authenticatedUserId(); + + $event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null); + $event->setRequest(self::scrubRequest($event->getRequest())); + + foreach ($event->getExceptions() as $exception) { + $exception->setValue(self::scrubText($exception->getValue())); + } + + if ($event->getMessage() !== null) { + $formatted = $event->getMessageFormatted(); + + $event->setMessage( + self::scrubText($event->getMessage()), + $event->getMessageParams(), + $formatted !== null ? self::scrubText($formatted) : null, + ); + } + + return $event; + } + + /** + * @param array $request + * @return array + */ + public static function scrubRequest(array $request): array + { + if (isset($request['url']) && is_string($request['url'])) { + $request['url'] = self::scrubUrl($request['url']); + } + + if (isset($request['query_string']) && is_string($request['query_string'])) { + $request['query_string'] = self::scrubQuery($request['query_string']); + } + + unset($request['data'], $request['cookies']); + + foreach ($request['headers'] ?? [] as $name => $values) { + if (strtolower((string) $name) === 'referer') { + $request['headers'][$name] = array_map( + static fn (mixed $value): string => self::scrubUrl((string) $value), + (array) $values, + ); + } + } + + return $request; + } + + public static function scrubUrl(string $url): string + { + $query = parse_url($url, PHP_URL_QUERY); + $withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url; + $scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery)); + + return $query !== null && $query !== false && $query !== '' + ? $scrubbedPath.'?'.self::scrubQuery($query) + : $scrubbedPath; + } + + public static function scrubQuery(string $query): string + { + parse_str($query, $parameters); + + $scrubbed = []; + foreach ($parameters as $name => $value) { + $isAllowed = in_array((string) $name, self::AllowedQueryParameters, true) + || str_starts_with((string) $name, 'utm_'); + + $scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered; + } + + return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986); + } + + public static function scrubPath(string $path): string + { + return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path)); + } + + public static function scrubText(string $text): string + { + return (string) preg_replace(self::EmailPattern, '[email]', $text); + } + + /** + * Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet + * (`token`, `hash`, `signature`), im Pfad der aktuellen Route. + */ + private static function scrubPathTokens(string $url): string + { + try { + $route = request()->route(); + } catch (Throwable) { + return $url; + } + + if ($route === null) { + return $url; + } + + foreach ($route->parameters() as $name => $value) { + if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) { + $url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url)); + } + } + + return $url; + } + + private static function authenticatedUserId(): int|string|null + { + try { + return Auth::id(); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Support/Monitoring/ErrorSignal.php b/app/Support/Monitoring/ErrorSignal.php new file mode 100644 index 00000000..7de730c4 --- /dev/null +++ b/app/Support/Monitoring/ErrorSignal.php @@ -0,0 +1,68 @@ + 'Toter interner Link', + self::AccessDenied => 'Zugriff verweigert (angemeldet)', + self::PageExpired => 'Seite abgelaufen (419)', + self::Throttled => 'Drosselung trifft angemeldeten Nutzer', + ]; + + private const MaxReportsPerWindow = 5; + + private const WindowSeconds = 600; + + /** + * @param array $context + */ + public function report(string $signal, string $location, array $context = []): bool + { + $location = Str::limit($location, 200, ''); + + $reported = RateLimiter::attempt( + 'error-signal:'.sha1($signal.'|'.$location), + self::MaxReportsPerWindow, + static fn (): bool => true, + self::WindowSeconds, + ); + + if ($reported === false) { + return false; + } + + withScope(function (Scope $scope) use ($signal, $location, $context): void { + $scope->setFingerprint(['error-signal', $signal, $location]); + $scope->setTag('signal', $signal); + $scope->setContext('signal', $context); + + captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning()); + }); + + return true; + } +} diff --git a/app/Support/Monitoring/Release.php b/app/Support/Monitoring/Release.php new file mode 100644 index 00000000..48e6aeb0 --- /dev/null +++ b/app/Support/Monitoring/Release.php @@ -0,0 +1,36 @@ + RELEASE`) — + * der Live-Server hat kein Git, solange per SFTP deployt wird. + * + * Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere, + * Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren + * Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“. + */ +class Release +{ + public static function current(?string $configured, ?string $basePath = null): ?string + { + $configured = trim((string) $configured); + + if ($configured !== '') { + return $configured; + } + + $file = rtrim($basePath ?? base_path(), '/').'/RELEASE'; + + if (! is_file($file)) { + return null; + } + + $release = trim((string) file_get_contents($file)); + + return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null; + } +} diff --git a/app/Support/OperationalAlert.php b/app/Support/OperationalAlert.php index 7240782d..814f0f06 100644 --- a/app/Support/OperationalAlert.php +++ b/app/Support/OperationalAlert.php @@ -3,15 +3,18 @@ namespace App\Support; use App\Mail\OperationalAlertMail; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Throwable; /** * Zentrale Betriebs-Alerts (R5.3): meldet fehlgeschlagene Scheduler-Tasks und * Queue-Jobs. Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft - * immer; die Mail ist Best-Effort und darf den auslösenden Prozess nie mit in - * den Abgrund reißen (Mailserver down o. ä.). + * immer; Mail und ntfy-Push (Monitoring-Plan M3) sind Best-Effort, unabhängig + * voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund + * reißen (Mailserver down, ntfy nicht erreichbar o. ä.). */ class OperationalAlert { @@ -26,6 +29,15 @@ class OperationalAlert return; } + self::mail($summary, $context); + self::push($summary, $context); + } + + /** + * @param array $context + */ + private static function mail(string $summary, array $context): void + { $recipient = config('monitoring.alerts.mail_to'); if (empty($recipient)) { @@ -41,4 +53,42 @@ class OperationalAlert ]); } } + + /** + * JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header). + * + * @param array $context + */ + private static function push(string $summary, array $context): void + { + $url = (string) config('monitoring.alerts.ntfy.url'); + $topic = (string) config('monitoring.alerts.ntfy.topic'); + + if ($url === '' || $topic === '') { + return; + } + + $details = collect($context) + ->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value))) + ->implode("\n"); + + try { + Http::timeout(5) + ->connectTimeout(3) + ->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token'))) + ->post(rtrim($url, '/'), [ + 'topic' => $topic, + 'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert', + 'message' => Str::limit(trim($summary."\n\n".$details), 3500), + 'priority' => (int) config('monitoring.alerts.ntfy.priority'), + 'tags' => ['rotating_light'], + ]) + ->throw(); + } catch (Throwable $e) { + Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [ + 'summary' => $summary, + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 00ae979b..b229b5be 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withRouting( @@ -28,6 +31,12 @@ return Application::configure(basePath: dirname(__DIR__)) // Legacy-API-Aliasse (/service/… ohne /api-Präfix) in der // api-Middleware-Gruppe — Übernahme bestehender Integrationen. Route::middleware('api')->group(__DIR__.'/../routes/legacy-api.php'); + + // Tiefer Health-Check für den externen Monitor (Monitoring-Plan + // 1.5) — ohne web-/api-Gruppe: keine Session, kein API-Log. + Route::get('/health', HealthController::class) + ->middleware('throttle:30,1') + ->name('health.deep'); }, ) ->withMiddleware(function (Middleware $middleware) { @@ -40,6 +49,11 @@ return Application::configure(basePath: dirname(__DIR__)) // Security-Header auf allen Antworten (Web + API), Proxy-unabhängig. $middleware->append(SecurityHeaders::class); + // Fehler ohne Exception (tote interne Links, 403/429 für Angemeldete, + // 419) als GlitchTip-Warnung — global, weil 404 vor jedem Routing + // entsteht (Monitoring-Plan 1.3). + $middleware->append(ReportErrorSignals::class); + // Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme')) $middleware->append(SetCurrentPortal::class); @@ -143,5 +157,9 @@ return Application::configure(basePath: dirname(__DIR__)) }, subdomains: false); }) ->withExceptions(function (Exceptions $exceptions) { - // + // Gemeldete Exceptions zusätzlich an GlitchTip (Monitoring-Plan 1.1). + // Ohne SENTRY_LARAVEL_DSN ein No-op; Laravels dontReport-Liste (404, + // Validierung, Auth) gilt weiter — diese Fälle meldet + // ReportErrorSignals gezielt als Warnung. + Integration::handles($exceptions); })->create(); diff --git a/config/app.php b/config/app.php index 53126c89..3ee49523 100644 --- a/config/app.php +++ b/config/app.php @@ -1,5 +1,7 @@ env('APP_NAME', 'Laravel'), + /* + |-------------------------------------------------------------------------- + | Application Version + |-------------------------------------------------------------------------- + | + | Ausgelieferter Stand (dev/betrieb/monitoring-plan.md, Versionierung): + | APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE + | (`git describe --tags --always --dirty`, z. B. „v1.1.0“). Dieselbe + | Kennung nutzen GlitchTip, /health und der Admin-Systemstatus. + | + */ + + 'version' => Release::current(env('APP_VERSION')), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/config/monitoring.php b/config/monitoring.php index 1cf6047d..cb928137 100644 --- a/config/monitoring.php +++ b/config/monitoring.php @@ -21,6 +21,54 @@ return [ // Ops-Postfach. Fällt auf die zentrale Rechts-/Betreiber-Adresse // zurück, solange kein dediziertes Monitoring-Postfach gesetzt ist. 'mail_to' => env('MONITORING_ALERT_EMAIL', env('LEGAL_CONTACT_EMAIL', 'info@pressekonto.com')), + + // Zweiter Alarmweg (Monitoring-Plan M3): Push über ntfy, JSON-POST + // an die Basis-URL. Ohne URL oder Topic aus. + 'ntfy' => [ + 'url' => env('MONITORING_NTFY_URL'), + 'topic' => env('MONITORING_NTFY_TOPIC'), + 'token' => env('MONITORING_NTFY_TOKEN'), + 'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Browserfehler (Monitoring-Plan 1.2) + |-------------------------------------------------------------------------- + | + | Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich + | (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne + | DSN wird kein Skript eingebunden. + | + */ + + 'browser' => [ + 'dsn' => env('SENTRY_BROWSER_DSN'), + 'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0), + ], + + /* + |-------------------------------------------------------------------------- + | Health-Endpunkt und Scheduler-Herzschlag (Monitoring-Plan 1.4, 1.5) + |-------------------------------------------------------------------------- + | + | `/health` antwortet nur mit gültigem Token (Bearer-Header oder + | `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404). + | Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist + | der Cron-Eintrag weg oder der Server hängt. + | + */ + + 'health' => [ + 'token' => env('MONITORING_HEALTH_TOKEN'), + 'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300), + 'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10), + ], + + 'scheduler' => [ + 'heartbeat_warn_minutes' => 3, + 'heartbeat_critical_minutes' => 10, ], ]; diff --git a/config/sentry.php b/config/sentry.php new file mode 100644 index 00000000..2590b2e1 --- /dev/null +++ b/config/sentry.php @@ -0,0 +1,180 @@ + env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) + // Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw. + // APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE. + 'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))), + + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT'), + + // Override the organization ID used for trace continuation checks. + 'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate + 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate + 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), + + // Only continue incoming traces when the organization IDs are compatible with this SDK instance. + 'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs + 'enable_logs' => env('SENTRY_ENABLE_LOGS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics + 'enable_metrics' => env('SENTRY_ENABLE_METRICS', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold + 'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'), + + // The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel + 'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii + 'send_default_pii' => false, + + // Formular- und JSON-Inhalte verlassen den Server nie. + 'max_request_body_size' => 'never', + + // User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen. + 'before_send' => [ErrorReportScrubber::class, 'beforeSend'], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions + // 'ignore_exceptions' => [], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + '/health', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Capture AI agent interactions as spans (requires laravel/ai) + 'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true), + + // Capture AI invoke_agent spans + 'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true), + + // Capture AI chat spans + 'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true), + + // Capture AI execute_tool spans + 'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true), + + // Capture AI embeddings spans + 'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), + ], + +]; diff --git a/config/services.php b/config/services.php index c60799dd..3b2ad398 100644 --- a/config/services.php +++ b/config/services.php @@ -57,5 +57,11 @@ return [ 'umami' => [ 'enabled' => env('UMAMI_ENABLED', false), 'script_url' => env('UMAMI_SCRIPT_URL', 'https://sys.adametz.media/config.js'), + + // Kundenbereich (Monitoring-Plan M2/M6): eigener Schalter, und die + // Zuordnung zur User-ID noch einmal getrennt — beides erst nach der + // Ergänzung der Datenschutzerklärung einschalten. + 'portal_enabled' => env('UMAMI_PORTAL_ENABLED', false), + 'identify_users' => env('UMAMI_IDENTIFY_USERS', false), ], ]; diff --git a/resources/js/error-reporting-sdk.js b/resources/js/error-reporting-sdk.js new file mode 100644 index 00000000..402c8a1a --- /dev/null +++ b/resources/js/error-reporting-sdk.js @@ -0,0 +1,5 @@ +/** + * Nur die benötigten SDK-Funktionen — der dynamische Import in + * error-reporting.js zieht so nicht das ganze Paket (Replay, Feedback …) nach. + */ +export { captureException, init, setTag, setUser } from '@sentry/browser'; diff --git a/resources/js/error-reporting.js b/resources/js/error-reporting.js new file mode 100644 index 00000000..644ecab1 --- /dev/null +++ b/resources/js/error-reporting.js @@ -0,0 +1,107 @@ +/** + * Browserfehler an GlitchTip (Monitoring-Plan 1.2, M5). + * + * Fehlerfreie Seiten laden nur diese paar Zeilen: Das Sentry-SDK wird erst + * beim ersten Fehler nachgeladen (eigener Chunk) und meldet dann genau diesen + * Fehler. Konfiguration kommt aus , das die + * Blade-Komponente x-monitoring.browser-errors setzt. + * + * Fehlgeschlagene Livewire-Requests (5xx, 419) meldet bereits der Server. + */ +const config = (() => { + const meta = document.querySelector('meta[name="pk-error-reporting"]'); + + try { + return meta ? JSON.parse(meta.content) : null; + } catch { + return null; + } +})(); + +const ALLOWED_QUERY = ['src', 'pr', 'ref', 'page', 'q', 'tab', 'edition']; +const TOKEN_SEGMENT = /(?<=\/)[A-Za-z0-9_-]{32,}(?=\/|$)/g; +const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; + +/** Gleiche Regeln wie ErrorReportScrubber auf dem Server. */ +function scrubUrl(value) { + try { + const url = new URL(value, window.location.origin); + url.pathname = url.pathname.replace(TOKEN_SEGMENT, '[Filtered]'); + for (const key of [...url.searchParams.keys()]) { + if (!ALLOWED_QUERY.includes(key) && !key.startsWith('utm_')) { + url.searchParams.set(key, '[Filtered]'); + } + } + url.hash = ''; + + return url.toString().replace(EMAIL, '[email]'); + } catch { + return '[Filtered]'; + } +} + +let sdk = null; + +function loadSdk() { + sdk ??= import('./error-reporting-sdk.js').then((Sentry) => { + Sentry.init({ + dsn: config.dsn, + release: config.release || undefined, + environment: config.environment, + sampleRate: config.sampleRate ?? 1, + sendDefaultPii: false, + // Eigene Listener unten statt GlobalHandlers (sonst doppelt); + // keine Breadcrumbs (Klicktexte, URLs) und keine Sessions. + integrations: (defaults) => + defaults.filter( + (integration) => + !['GlobalHandlers', 'Breadcrumbs', 'BrowserSession', 'BrowserApiErrors'].includes(integration.name), + ), + ignoreErrors: ['ResizeObserver loop', 'Script error.', 'AbortError'], + denyUrls: [/^chrome-extension:\/\//, /^moz-extension:\/\//, /^safari-(web-)?extension:\/\//], + beforeSend(event) { + if (event.request?.url) { + event.request.url = scrubUrl(event.request.url); + } + if (event.request?.headers?.Referer) { + event.request.headers.Referer = scrubUrl(event.request.headers.Referer); + } + for (const exception of event.exception?.values ?? []) { + if (exception.value) { + exception.value = exception.value.replace(EMAIL, '[email]'); + } + } + + return event; + }, + }); + + if (config.userId) { + Sentry.setUser({ id: String(config.userId) }); + } + Sentry.setTag('area', config.area); + + return Sentry; + }); + + return sdk; +} + +function capture(error) { + loadSdk() + .then((Sentry) => Sentry.captureException(error)) + .catch(() => {}); +} + +if (config?.dsn) { + window.addEventListener('error', (event) => { + // Ressourcenfehler (Bild, Skript) haben kein `error` — nur echte Skriptfehler. + if (event.error) { + capture(event.error); + } + }); + + window.addEventListener('unhandledrejection', (event) => { + capture(event.reason instanceof Error ? event.reason : new Error(String(event.reason))); + }); +} diff --git a/resources/views/components/layouts/auth/pressekonto.blade.php b/resources/views/components/layouts/auth/pressekonto.blade.php index 1ce9c8a1..0c269a75 100644 --- a/resources/views/components/layouts/auth/pressekonto.blade.php +++ b/resources/views/components/layouts/auth/pressekonto.blade.php @@ -59,6 +59,7 @@ wire:model würden brechen (siehe Browser-Logs vor diesem Fix). --}} @vite([$themeCssPath], $assetsDir) @livewireStyles + diff --git a/resources/views/components/monitoring/browser-errors.blade.php b/resources/views/components/monitoring/browser-errors.blade.php new file mode 100644 index 00000000..991cfac7 --- /dev/null +++ b/resources/views/components/monitoring/browser-errors.blade.php @@ -0,0 +1,14 @@ +@props(['build' => 'build/web', 'area' => 'web']) +{{-- Browserfehler an GlitchTip (Monitoring-Plan 1.2). Ohne SENTRY_BROWSER_DSN + wird nichts eingebunden; das SDK selbst lädt erst beim ersten Fehler. --}} +@if (filled(config('monitoring.browser.dsn'))) + + @vite(['resources/js/error-reporting.js'], $build) +@endif diff --git a/resources/views/components/web/umami.blade.php b/resources/views/components/web/umami.blade.php index 1d26a5d3..b33f55a4 100644 --- a/resources/views/components/web/umami.blade.php +++ b/resources/views/components/web/umami.blade.php @@ -1,13 +1,57 @@ +@props(['area' => 'web']) @php // Selbst gehostete, cookielose Webanalyse (Datenschutz Abschnitt 5). - // Deaktiviert bis zum Go-Live (UMAMI_ENABLED, config/services.php); - // die Website-ID kommt je Brand aus config/domains.php. + // Webseiten: UMAMI_ENABLED. Kundenbereich (Monitoring-Plan M2, M6, M7): + // zusätzlich UMAMI_PORTAL_ENABLED, nur unter `me.*` und nie während einer + // Impersonation; die Zuordnung zur User-ID nur mit UMAMI_IDENTIFY_USERS. + // Die Website-ID kommt je Brand aus config/domains.php, `data-tag` + // trennt Portal und Webseiten in der Auswertung. $umamiHost = request()->getHost(); $umamiBrandKey = str_contains($umamiHost, 'pressekonto') ? 'pressekonto' : (str_contains($umamiHost, 'presseecho') ? 'presseecho' : 'businessportal24'); $umamiWebsiteId = config('domains.domains.'.$umamiBrandKey.'.brand.umami_website_id'); + $umamiIsPortal = $area === 'portal'; + $umamiEnabled = config('services.umami.enabled') && $umamiWebsiteId && (! $umamiIsPortal || ( + config('services.umami.portal_enabled') + && request()->routeIs('me.*') + && ! app(\App\Actions\Admin\UserImpersonation::class)->isActive() + )); + $umamiIdentity = $umamiIsPortal && config('services.umami.identify_users') && auth()->check() + ? 'user-'.auth()->id() + : null; @endphp -@if (config('services.umami.enabled') && $umamiWebsiteId) - +@if ($umamiEnabled) + {{-- Query-Filter (M8): Nur utm_*, src, pr, ref, page, q, tab gehen raus — + signierte Links und Tokens nie. Tokenartige Pfadsegmente ebenso. --}} + + @endif diff --git a/resources/views/livewire/admin/reports/system-health.blade.php b/resources/views/livewire/admin/reports/system-health.blade.php index c72670d3..1c315e1f 100644 --- a/resources/views/livewire/admin/reports/system-health.blade.php +++ b/resources/views/livewire/admin/reports/system-health.blade.php @@ -56,6 +56,7 @@ new #[Layout('components.layouts.app'), Title('Betriebsüberwachung')] class ext

{{ __('Stand') }}: {{ $health['generated_at']->format('d.m.Y H:i:s') }} + · {{ __('Version') }}: {{ config('app.version') ?? __('unbekannt (keine RELEASE-Datei)') }}

{{-- ============== QUEUE-KPIs ============== --}} diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index b2fecb5d..57994aeb 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -3,6 +3,7 @@ use App\Models\User; use App\Services\Auth\LoginCompletion; use App\Support\LoginRedirect; +use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Lockout; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -40,6 +41,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu if (! $user || ! $user->password || ! Hash::check($this->password, $user->password)) { RateLimiter::hit($this->throttleKey()); + // Standard-Event wie bei Auth::attempt() — ohne Passwort; der + // Aktivitäts-Verlauf leitet den Grund am User ab (Monitoring 1.7). + event(new Failed('web', $user, ['email' => $this->email])); + throw ValidationException::withMessages([ 'email' => __('auth.failed'), ]); @@ -98,7 +103,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu return; } - event(new Lockout(request())); + // Die Adresse steckt im Livewire-Payload, nicht im Request-Input — + // für den Aktivitäts-Verlauf mitgeben. + event(new Lockout(request()->duplicate(request: ['email' => $this->email]))); $seconds = RateLimiter::availableIn($this->throttleKey()); diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 14713fcc..ca706110 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -43,3 +43,7 @@ writeCookie(); })(); + + + + diff --git a/resources/views/web/layouts/web-master.blade.php b/resources/views/web/layouts/web-master.blade.php index 022ee01c..c8fe6b9f 100644 --- a/resources/views/web/layouts/web-master.blade.php +++ b/resources/views/web/layouts/web-master.blade.php @@ -154,6 +154,7 @@ @stack('styles') + diff --git a/routes/console.php b/routes/console.php index a257e34f..9aa1079a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -10,6 +10,7 @@ use App\Console\Commands\CheckMailDomains; use App\Console\Commands\ExpireGrandfathering; use App\Console\Commands\FetchMailBounces; use App\Console\Commands\GenerateManualInvoices; +use App\Console\Commands\MonitoringHeartbeat; use App\Console\Commands\PublishScheduledPressReleases; use App\Console\Commands\PurgeConversionEvents; use App\Console\Commands\PurgeExpiredPressReleaseDrafts; @@ -232,3 +233,22 @@ Schedule::command(RefreshMarketData::class) ->withoutOverlapping(15) ->runInBackground() ->onFailure($alertOnFailure('market:refresh')); + +// ======================================== +// Betriebsüberwachung (Monitoring-Plan Phase 1) +// ======================================== + +// Herzschlag: Fällt der Scheduler komplett aus (Cron weg, Server hängt), +// greift kein onFailure mehr — dann meldet /health den fehlenden Herzschlag. +Schedule::command(MonitoringHeartbeat::class) + ->everyMinute() + ->withoutOverlapping(2) + ->onFailure($alertOnFailure('monitoring:heartbeat')); + +// Fehlgeschlagene Jobs nach 30 Tagen entfernen (täglich 03:55). Bis dahin +// sind sie im Systemstatus sichtbar und per `queue:retry` wiederholbar. +Schedule::command('queue:prune-failed', ['--hours=720']) + ->dailyAt('03:55') + ->withoutOverlapping(30) + ->runInBackground() + ->onFailure($alertOnFailure('queue:prune-failed')); diff --git a/tests/Feature/Admin/SystemHealthTest.php b/tests/Feature/Admin/SystemHealthTest.php index f14936aa..da9be2e0 100644 --- a/tests/Feature/Admin/SystemHealthTest.php +++ b/tests/Feature/Admin/SystemHealthTest.php @@ -40,6 +40,18 @@ test('admin can open the system health report', function () { ->assertSee('Queue-Worker'); }); +test('the system health report shows the deployed version', function () { + /** @var TestCase $this */ + config()->set('app.version', 'v1.1.0'); + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + $this->actingAs($admin); + + $this->get('https://pressekonto.test/admin/reports/system-health') + ->assertSuccessful() + ->assertSee('Version: v1.1.0'); +}); + test('snapshot reports an ok queue when there are no waiting jobs', function () { /** @var TestCase $this */ $snapshot = app(AdminSystemHealth::class)->snapshot(); diff --git a/tests/Feature/Auth/FailedLoginActivityTest.php b/tests/Feature/Auth/FailedLoginActivityTest.php new file mode 100644 index 00000000..7f5d7b05 --- /dev/null +++ b/tests/Feature/Auth/FailedLoginActivityTest.php @@ -0,0 +1,76 @@ +where('type', $type->value)->orderBy('id')->get(); +} + +test('a wrong password is recorded with the account and the reason', function () { + $user = User::factory()->create(['email' => 'kunde@example.com']); + + Volt::test('auth.login') + ->set('email', 'kunde@example.com') + ->set('password', 'falsches-passwort') + ->call('login') + ->assertHasErrors('email'); + + $event = failedLoginEvents(ActivityType::LoginFailed)->sole(); + + expect($event->user_id)->toBe($user->id) + ->and($event->meta['reason'])->toBe('wrong_password') + ->and($event->meta['ip_hash'])->toHaveLength(12) + ->and($event->description)->toContain('falsches Passwort') + ->and(json_encode($event->toArray()))->not->toContain('falsches-passwort') + ->and(json_encode($event->toArray()))->not->toContain('127.0.0.1'); +}); + +test('an account without password is told apart from an unknown address', function () { + User::factory()->create(['email' => 'legacy@example.com', 'password' => null]); + + foreach (['legacy@example.com', 'niemand@example.com'] as $email) { + Volt::test('auth.login')->set('email', $email)->set('password', 'irgendwas')->call('login'); + } + + $events = failedLoginEvents(ActivityType::LoginFailed); + + expect($events->pluck('meta.reason')->all())->toBe(['no_password', 'unknown_account']) + ->and($events[1]->user_id)->toBeNull() + ->and($events[1]->description)->not->toContain('niemand@example.com'); +}); + +test('a lockout is recorded for the targeted account', function () { + $user = User::factory()->create(['email' => 'kunde@example.com']); + + foreach (range(1, 6) as $attempt) { + Volt::test('auth.login')->set('email', 'kunde@example.com')->set('password', 'falsch')->call('login'); + } + + $lockout = failedLoginEvents(ActivityType::LoginLockout)->sole(); + + expect($lockout->user_id)->toBe($user->id) + ->and($lockout->description)->toContain('gesperrt') + ->and(failedLoginEvents(ActivityType::LoginFailed))->toHaveCount(5); +}); + +test('a wrong two-factor code is recorded', function () { + $user = User::factory()->create(['is_active' => true]); + $user->forceFill([ + 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), + 'two_factor_confirmed_at' => now(), + ])->save(); + + $this->withSession(['login.id' => $user->id, 'login.remember' => false]) + ->post('/two-factor-challenge', ['code' => '000000']); + + expect(failedLoginEvents(ActivityType::TwoFactorFailed)->sole()->user_id)->toBe($user->id); +}); diff --git a/tests/Feature/Monitoring/BrowserErrorReportingTest.php b/tests/Feature/Monitoring/BrowserErrorReportingTest.php new file mode 100644 index 00000000..515eceee --- /dev/null +++ b/tests/Feature/Monitoring/BrowserErrorReportingTest.php @@ -0,0 +1,38 @@ +withoutVite(); +}); + +test('nothing is rendered without a browser dsn', function () { + config()->set('monitoring.browser.dsn', null); + + expect(Blade::render(''))->toBe(''); +}); + +test('the configuration carries dsn, release and the user id only', function () { + config()->set('monitoring.browser.dsn', 'https://public@glitchtip.test/2'); + config()->set('sentry.release', 'abc1234'); + $user = User::factory()->create(['email' => 'kunde@example.com']); + $this->actingAs($user); + + $html = Blade::render(''); + + preg_match('/toMatchArray([ + 'dsn' => 'https://public@glitchtip.test/2', + 'release' => 'abc1234', + 'userId' => $user->id, + 'area' => 'portal', + ]) + ->and($html)->not->toContain('kunde@example.com'); +}); diff --git a/tests/Feature/Monitoring/ErrorReportingTest.php b/tests/Feature/Monitoring/ErrorReportingTest.php new file mode 100644 index 00000000..3a9c87e1 --- /dev/null +++ b/tests/Feature/Monitoring/ErrorReportingTest.php @@ -0,0 +1,77 @@ +create(['email' => 'kunde@example.com']); + $this->actingAs($user); + + report(new RuntimeException('Import für kunde@example.com fehlgeschlagen')); + + expect($glitchTip->events)->toHaveCount(1); + + $event = $glitchTip->events[0]; + expect($event->getUser()?->getId())->toBe($user->id) + ->and($event->getUser()?->getEmail())->toBeNull() + ->and($event->getExceptions()[0]->getValue())->toBe('Import für [email] fehlgeschlagen'); +}); + +test('exceptions laravel does not report stay out of glitchtip', function () { + $glitchTip = FakeGlitchTip::install(); + + $this->get('/diese-seite-gibt-es-nicht')->assertNotFound(); + + expect($glitchTip->events)->toBe([]); +}); + +test('request data loses tokens, signatures and bodies', function () { + $scrubbed = ErrorReportScrubber::scrubRequest([ + 'url' => 'https://pressekonto.test/newsroom/claim/5?signature=abc123&expires=1700000000&src=mail_publish', + 'query_string' => 'signature=abc123&expires=1700000000&src=mail_publish&utm_source=nl', + 'data' => ['password' => 'geheim'], + 'cookies' => ['pressekonto_session' => 'x'], + 'headers' => ['referer' => ['https://pressekonto.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']], + ]); + + expect($scrubbed['url'])->toBe('https://pressekonto.test/newsroom/claim/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish') + ->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&src=mail_publish&utm_source=nl') + ->and($scrubbed)->not->toHaveKeys(['data', 'cookies']) + ->and($scrubbed['headers']['referer'][0])->toBe('https://pressekonto.test/reset-password/[Filtered]?email=%5BFiltered%5D'); +}); + +test('route parameters named like secrets are removed from the url', function () { + Route::middleware('web')->get('/_test/magic/{token}', function () { + $event = Event::createEvent(); + $event->setRequest(['url' => request()->fullUrl()]); + + return ErrorReportScrubber::beforeSend($event)?->getRequest()['url']; + }); + + $this->get('/_test/magic/kurz123') + ->assertSuccessful() + ->assertSee('/_test/magic/[Filtered]', false) + ->assertDontSee('kurz123'); +}); + +test('messages and exception values lose e-mail addresses', function () { + $event = Event::createEvent(); + $event->setMessage('Bounce von info@firma.de', [], 'Bounce von info@firma.de'); + $event->setExceptions([new ExceptionDataBag(new RuntimeException('Duplicate entry \'a.b@c.org\''))]); + + $scrubbed = ErrorReportScrubber::beforeSend($event); + + expect($scrubbed?->getMessage())->toBe('Bounce von [email]') + ->and($scrubbed?->getMessageFormatted())->toBe('Bounce von [email]') + ->and($scrubbed?->getExceptions()[0]->getValue())->toBe('Duplicate entry \'[email]\'') + ->and($scrubbed?->getUser())->toBeNull(); +}); diff --git a/tests/Feature/Monitoring/ErrorSignalsTest.php b/tests/Feature/Monitoring/ErrorSignalsTest.php new file mode 100644 index 00000000..a981c08f --- /dev/null +++ b/tests/Feature/Monitoring/ErrorSignalsTest.php @@ -0,0 +1,71 @@ +withoutDefer(); + $this->glitchTip = FakeGlitchTip::install(); + + Route::middleware('web')->get('/_test/forbidden', fn () => abort(403))->name('test.forbidden'); + Route::middleware('web')->get('/_test/expired', fn () => abort(419))->name('test.expired'); + Route::middleware('web')->get('/_test/throttled', fn () => abort(429))->name('test.throttled'); +}); + +test('a 404 reached from one of our own pages is reported as a dead internal link', function () { + $this->get('/gibt-es-nicht', ['referer' => url('/de/presse')])->assertNotFound(); + + expect($this->glitchTip->messages())->toBe(['Toter interner Link: /gibt-es-nicht']); + + $event = $this->glitchTip->events[0]; + expect($event->getTags())->toMatchArray(['signal' => ErrorSignal::DeadInternalLink]) + ->and($event->getFingerprint())->toBe(['error-signal', ErrorSignal::DeadInternalLink, '/gibt-es-nicht']) + ->and($event->getContexts()['signal']['referer'])->toBe('/de/presse'); +}); + +test('a 404 without referer or from another site is not reported', function () { + $this->get('/gibt-es-nicht')->assertNotFound(); + $this->get('/gibt-es-nicht', ['referer' => 'https://google.com/search'])->assertNotFound(); + + expect($this->glitchTip->events)->toBe([]); +}); + +test('403 and 429 are reported only for signed-in users', function () { + $this->get('/_test/forbidden')->assertForbidden(); + $this->get('/_test/throttled')->assertTooManyRequests(); + + expect($this->glitchTip->events)->toBe([]); + + $this->actingAs(User::factory()->create()); + $this->get('/_test/forbidden')->assertForbidden(); + $this->get('/_test/throttled')->assertTooManyRequests(); + + expect($this->glitchTip->messages())->toBe([ + 'Zugriff verweigert (angemeldet): test.forbidden', + 'Drosselung trifft angemeldeten Nutzer: test.throttled', + ]); +}); + +test('an expired page is always reported, for livewire with the page as location', function () { + $this->get('/_test/expired')->assertStatus(419); + $this->get('/_test/expired', ['X-Livewire' => '1', 'referer' => url('/me/pressemitteilungen/neu')])->assertStatus(419); + + expect($this->glitchTip->messages())->toBe([ + 'Seite abgelaufen (419): test.expired', + 'Seite abgelaufen (419): livewire /me/pressemitteilungen/neu', + ]); +}); + +test('each signal group is capped at five reports per window', function () { + foreach (range(1, 7) as $attempt) { + $this->get('/_test/expired'); + } + + expect($this->glitchTip->events)->toHaveCount(5); +}); diff --git a/tests/Feature/Monitoring/HealthEndpointTest.php b/tests/Feature/Monitoring/HealthEndpointTest.php new file mode 100644 index 00000000..0fef912e --- /dev/null +++ b/tests/Feature/Monitoring/HealthEndpointTest.php @@ -0,0 +1,102 @@ +set('monitoring.health.token', 'geheimes-monitor-token'); + config()->set('monitoring.health.disk_min_free_percent', 0); + config()->set('queue.default', 'database'); +}); + +function healthHeaders(): array +{ + return ['Authorization' => 'Bearer geheimes-monitor-token']; +} + +test('the endpoint does not exist without a configured token or with a wrong one', function () { + app(HealthProbe::class)->recordHeartbeat(); + + $this->get('/health', healthHeaders())->assertOk(); + + $this->get('/health', ['Authorization' => 'Bearer falsch'])->assertNotFound(); + $this->get('/health')->assertNotFound(); + + config()->set('monitoring.health.token', null); + $this->get('/health', healthHeaders())->assertNotFound(); +}); + +test('a healthy system answers 200 with every check', function () { + config()->set('app.version', 'v1.1.0'); + $this->artisan('monitoring:heartbeat')->assertSuccessful(); + + $this->get('/health?token=geheimes-monitor-token') + ->assertOk() + ->assertHeader('Cache-Control', 'no-store, private') + ->assertJsonPath('status', 'ok') + ->assertJsonPath('version', 'v1.1.0') + ->assertJsonPath('checks.database.status', 'ok') + ->assertJsonPath('checks.cache.status', 'ok') + ->assertJsonPath('checks.queue.status', 'ok') + ->assertJsonPath('checks.scheduler.status', 'ok') + ->assertJsonStructure(['checks' => ['disk' => ['status', 'detail']]]); +}); + +test('a missing or stale heartbeat makes the endpoint fail', function () { + $this->get('/health', healthHeaders()) + ->assertStatus(503) + ->assertJsonPath('checks.scheduler.status', 'critical'); + + app(HealthProbe::class)->recordHeartbeat(); + + $this->travel(4)->minutes(); + $this->get('/health', healthHeaders())->assertOk()->assertJsonPath('checks.scheduler.status', 'warn'); + + $this->travel(7)->minutes(); + $this->get('/health', healthHeaders())->assertStatus(503)->assertJsonPath('checks.scheduler.status', 'critical'); +}); + +test('a queue backlog older than five minutes makes the endpoint fail', function () { + app(HealthProbe::class)->recordHeartbeat(); + + DB::table('jobs')->insert([ + 'queue' => 'default', + 'payload' => '{}', + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => now()->subMinutes(6)->getTimestamp(), + 'created_at' => now()->subMinutes(6)->getTimestamp(), + ]); + + $this->get('/health', healthHeaders()) + ->assertStatus(503) + ->assertJsonPath('checks.queue.status', 'critical'); +}); + +test('the endpoint answers on every brand domain and skips basic auth', function (string $domainKey) { + config()->set('auth.basic.enabled', true); + config()->set('auth.basic.user', 'staging'); + config()->set('auth.basic.password', 'staging'); + app(HealthProbe::class)->recordHeartbeat(); + + $this->get('http://'.config($domainKey).'/health', healthHeaders()) + ->assertOk() + ->assertJsonPath('status', 'ok'); +})->with(['domains.domain_portal', 'domains.domain_presseecho', 'domains.domain_businessportal']); + +test('the admin system health shows scheduler and disk', function () { + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + + expect($checks->get('scheduler')['status'])->toBe('critical') + ->and($checks->get('scheduler')['detail'])->toContain('Noch kein Herzschlag') + ->and($checks)->toHaveKey('disk'); + + app(HealthProbe::class)->recordHeartbeat(); + + $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); + expect($checks->get('scheduler')['status'])->toBe('ok'); +}); diff --git a/tests/Feature/OperationalAlertTest.php b/tests/Feature/OperationalAlertTest.php index dbc1c08f..a6713e45 100644 --- a/tests/Feature/OperationalAlertTest.php +++ b/tests/Feature/OperationalAlertTest.php @@ -3,7 +3,9 @@ use App\Mail\OperationalAlertMail; use App\Support\OperationalAlert; use Illuminate\Contracts\Queue\Job; +use Illuminate\Http\Client\Request; use Illuminate\Queue\Events\JobFailed; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Psr\Log\LoggerInterface; @@ -74,3 +76,57 @@ test('a failing queue job triggers an operational alert via the registered liste return str_contains($mail->summary, 'ClassifyPressRelease'); }); }); + +test('an alert is also pushed to ntfy as json with token and priority', function () { + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', null); + config()->set('monitoring.alerts.ntfy', [ + 'url' => 'https://ntfy.example.com/', + 'topic' => 'pressekonto-ops', + 'token' => 'tk_geheim', + 'priority' => 4, + ]); + Http::fake(['ntfy.example.com/*' => Http::response(['id' => 'abc'])]); + + OperationalAlert::send('Geplanter Task fehlgeschlagen: archive:purge', ['exit_code' => 1, 'queue' => ['a' => 'b']]); + + Http::assertSent(function (Request $request) { + return $request->url() === 'https://ntfy.example.com' + && $request->hasHeader('Authorization', 'Bearer tk_geheim') + && $request['topic'] === 'pressekonto-ops' + && $request['priority'] === 4 + && str_contains($request['message'], 'archive:purge') + && str_contains($request['message'], 'exit_code: 1') + && str_contains($request['message'], 'queue: {"a":"b"}'); + }); +}); + +test('a failing ntfy push neither bubbles up nor stops the mail', function () { + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.mail_to', 'ops@example.com'); + config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com'); + config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops'); + Mail::fake(); + Http::fake(['ntfy.example.com/*' => Http::response('down', 502)]); + + OperationalAlert::send('Etwas ist kaputt'); + + Mail::assertSent(OperationalAlertMail::class); + Http::assertSentCount(1); +}); + +test('no push is sent when alerts are disabled or ntfy is not configured', function () { + Http::fake(); + config()->set('monitoring.alerts.ntfy.url', 'https://ntfy.example.com'); + config()->set('monitoring.alerts.ntfy.topic', null); + config()->set('monitoring.alerts.enabled', true); + + OperationalAlert::send('Etwas ist kaputt'); + + config()->set('monitoring.alerts.ntfy.topic', 'pressekonto-ops'); + config()->set('monitoring.alerts.enabled', false); + + OperationalAlert::send('Etwas ist kaputt'); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Web/UmamiSnippetTest.php b/tests/Feature/Web/UmamiSnippetTest.php index 847bacaa..a194990b 100644 --- a/tests/Feature/Web/UmamiSnippetTest.php +++ b/tests/Feature/Web/UmamiSnippetTest.php @@ -1,5 +1,8 @@ ['businessportal24.test', 'f03a5fc3-ac44-488f-9118-84004861099f'], 'pressekonto hub' => ['pressekonto.test', 'ca440b65-a5bd-42c1-8cdb-96f34734003c'], ]); + +test('the web snippet carries the query filter, web vitals and no identity', function () { + /** @var TestCase $this */ + config()->set('services.umami.enabled', true); + config()->set('services.umami.identify_users', true); + $this->actingAs(User::factory()->create()); + + $this->get('https://presseecho.test/de/faq') + ->assertSuccessful() + ->assertSee('data-tag="web"', false) + ->assertSee('data-before-send="pkUmamiBeforeSend"', false) + ->assertSee('window.pkUmamiBeforeSend', false) + ->assertSee('data-performance="true"', false) + ->assertDontSee('umami.identify', false); +}); + +/** + * Kundenbereich (Monitoring-Plan M2, M6, M7). + */ +function umamiCustomer(): User +{ + test()->seed(RolesAndPermissionsSeeder::class); + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + return $customer; +} + +test('the customer area is tracked only with its own switch', function () { + /** @var TestCase $this */ + config()->set('services.umami.enabled', true); + config()->set('services.umami.portal_enabled', false); + $this->actingAs(umamiCustomer()); + + $this->get(route('me.dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false); + + config()->set('services.umami.portal_enabled', true); + + $this->get(route('me.dashboard')) + ->assertSuccessful() + ->assertSee('data-tag="portal"', false) + ->assertSee('data-website-id="ca440b65-a5bd-42c1-8cdb-96f34734003c"', false) + ->assertDontSee('umami.identify', false); +}); + +test('customers are identified by user id only when identify is switched on', function () { + /** @var TestCase $this */ + config()->set('services.umami.enabled', true); + config()->set('services.umami.portal_enabled', true); + config()->set('services.umami.identify_users', true); + $customer = umamiCustomer(); + $this->actingAs($customer); + + $this->get(route('me.dashboard')) + ->assertSuccessful() + ->assertSee("window.umami.identify('user-{$customer->id}')", false) + ->assertDontSee("identify('{$customer->email}", false); +}); + +test('the admin area and impersonated sessions are never tracked', function () { + /** @var TestCase $this */ + config()->set('services.umami.enabled', true); + config()->set('services.umami.portal_enabled', true); + config()->set('services.umami.identify_users', true); + $customer = umamiCustomer(); + $admin = User::factory()->create(['is_active' => true, 'is_super_admin' => true]); + + $this->actingAs($admin)->get(route('dashboard'))->assertSuccessful()->assertDontSee('sys.adametz.media', false); + + $this->actingAs($customer) + ->withSession([UserImpersonation::SessionKey => $admin->id]) + ->get(route('me.dashboard')) + ->assertSuccessful() + ->assertDontSee('sys.adametz.media', false); +}); diff --git a/tests/Support/FakeGlitchTip.php b/tests/Support/FakeGlitchTip.php new file mode 100644 index 00000000..f3c38b1a --- /dev/null +++ b/tests/Support/FakeGlitchTip.php @@ -0,0 +1,63 @@ +events + */ +class FakeGlitchTip implements TransportInterface +{ + /** @var list */ + public array $events = []; + + public static function install(): self + { + $transport = new self; + + $client = ClientBuilder::create([ + 'dsn' => 'https://public@glitchtip.test/1', + 'default_integrations' => false, + 'send_default_pii' => config('sentry.send_default_pii'), + 'max_request_body_size' => config('sentry.max_request_body_size'), + 'before_send' => config('sentry.before_send'), + ])->setTransport($transport)->getClient(); + + SentrySdk::getCurrentHub()->bindClient($client); + + return $transport; + } + + public function send(Event $event): Result + { + $this->events[] = $event; + + return new Result(ResultStatus::success(), $event); + } + + public function close(?int $timeout = null): Result + { + return new Result(ResultStatus::success()); + } + + /** + * @return list + */ + public function messages(): array + { + return array_values(array_map( + static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()), + $this->events, + )); + } +} diff --git a/tests/Unit/Monitoring/ReleaseTest.php b/tests/Unit/Monitoring/ReleaseTest.php new file mode 100644 index 00000000..db096ea4 --- /dev/null +++ b/tests/Unit/Monitoring/ReleaseTest.php @@ -0,0 +1,38 @@ +dir = sys_get_temp_dir().'/release-test-'.uniqid(); + mkdir($this->dir); +}); + +afterEach(function () { + @unlink($this->dir.'/RELEASE'); + @rmdir($this->dir); +}); + +test('the configured value wins over the release file', function () { + file_put_contents($this->dir.'/RELEASE', "abc1234\n"); + + expect(Release::current('v1.2.0', $this->dir))->toBe('v1.2.0'); +}); + +test('the release file is used when nothing is configured', function () { + file_put_contents($this->dir.'/RELEASE', "98c465b-dirty\n"); + + expect(Release::current(null, $this->dir))->toBe('98c465b-dirty') + ->and(Release::current(' ', $this->dir))->toBe('98c465b-dirty'); +}); + +test('without file or with garbage there is no release', function () { + expect(Release::current(null, $this->dir))->toBeNull(); + + file_put_contents($this->dir.'/RELEASE', ' # bei jedem Deploy'); + + expect(Release::current(null, $this->dir))->toBeNull(); +}); diff --git a/vite.portal.config.js b/vite.portal.config.js index a19c8626..709c1d9c 100644 --- a/vite.portal.config.js +++ b/vite.portal.config.js @@ -28,6 +28,7 @@ export default defineConfig({ "resources/css/portal.css", "resources/js/app.js", "resources/js/portal-form-hooks.js", + "resources/js/error-reporting.js", ], refresh: portalRefreshPaths, hotFile: PORTAL_HOT_FILE, diff --git a/vite.web.config.js b/vite.web.config.js index 0051c895..0c790d34 100644 --- a/vite.web.config.js +++ b/vite.web.config.js @@ -30,6 +30,7 @@ export default defineConfig({ "resources/css/web/theme-presseecho.css", "resources/css/web/theme-pressekonto.css", "resources/js/app.js", + "resources/js/error-reporting.js", ], refresh: webRefreshPaths, hotFile: WEB_HOT_FILE,