Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang

- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 08:56:23 +00:00
parent fcb10be120
commit b5ed756d67
98 changed files with 3947 additions and 82 deletions

View file

@ -85,3 +85,28 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
SEED_ADMIN_NAME="Admin"
SEED_ADMIN_EMAIL=admin@b2in.test
SEED_ADMIN_PASSWORD=
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
APP_VERSION=
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
SENTRY_LARAVEL_DSN=
SENTRY_BROWSER_DSN=
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
SENTRY_RELEASE=
# --- Betriebs-Alerts (Mail und ntfy-Push)
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
MONITORING_NTFY_URL=https://push.adametz.media
MONITORING_NTFY_TOPIC=b2in-ops
MONITORING_NTFY_TOKEN=
MONITORING_NTFY_PRIORITY=4
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
MONITORING_HEALTH_TOKEN=
MONITORING_QUEUE_BACKLOG_SECONDS=300
MONITORING_DISK_MIN_FREE_PERCENT=10
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
GLITCHTIP_API_TOKEN=

10
.gitignore vendored
View file

@ -1,12 +1,17 @@
# Laravel
/.phpunit.cache
/node_modules
/public/build
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
/public/build/assets/
/public/build/manifest.json
/public/hot
/public/storage
/public/vendor
/public/flux
/storage/*.key
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
/RELEASE
/storage/app
/storage/framework
/storage/language
@ -50,7 +55,8 @@ yarn-error.log
Network Trash Folder
Temporary Items
.apdisk
Icon
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
Icon[\r]
# Project specific

View file

@ -142,6 +142,11 @@ For local development, add domains to your hosts file:
127.0.0.1 style2own.test
```
## Deployment & Monitoring
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
## Important Files
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
@ -160,7 +165,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.18
- php - 8.4.26
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0

View file

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Console\Command;
/**
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
*/
class MonitoringHeartbeat extends Command
{
protected $signature = 'monitoring:heartbeat';
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
public function handle(HealthProbe $probe): int
{
$probe->recordHeartbeat();
return self::SUCCESS;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Monitoring\QueueProbe;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
/**
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
*
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
*/
class MonitoringQueueProbe extends Command
{
protected $signature = 'monitoring:queue-probe
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
public function handle(): int
{
$token = Str::random(16);
$wait = max(0, (int) $this->option('wait'));
QueueProbe::dispatch($token);
for ($second = 0; $second <= $wait; $second++) {
if (Cache::has(QueueProbe::cacheKey($token))) {
Cache::forget(QueueProbe::cacheKey($token));
$this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second));
return self::SUCCESS;
}
if ($second < $wait) {
Sleep::for(1)->second();
}
}
$this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait));
$this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &');
return self::FAILURE;
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tiefer Health-Check für den externen Monitor.
*
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
*/
class HealthController extends Controller
{
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
{
$token = (string) config('monitoring.health.token');
$given = (string) ($request->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');
}
}

View file

@ -32,6 +32,12 @@ class BasicAuthMiddleware
return $next($request);
}
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
if ($path === 'health' || $path === 'up') {
return $next($request);
}
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
if (str_starts_with($path, 'flux/')) {
return $next($request);

View file

@ -0,0 +1,96 @@
<?php
namespace App\Http\Middleware;
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\ErrorSignal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use function Illuminate\Support\defer;
/**
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
* obwohl keine Exception gemeldet wird:
*
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
*
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
*/
class ReportErrorSignals
{
public function __construct(private readonly ErrorSignal $errorSignal) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$signal = $this->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;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Jobs\Monitoring;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
/**
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
* der Worker muss sie bedienen.
*/
class QueueProbe implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public string $token) {}
public static function cacheKey(string $token): string
{
return 'monitoring:queue-probe:'.$token;
}
public function handle(): void
{
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
}
}

View file

@ -2,6 +2,10 @@
namespace App\Providers;
use App\Support\OperationalAlert;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@ -29,5 +33,17 @@ class AppServiceProvider extends ServiceProvider
if ($scheme === 'https') {
URL::forceScheme('https');
}
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
Queue::failing(function (JobFailed $event): void {
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
}
}

View file

@ -0,0 +1,198 @@
<?php
namespace App\Services\Monitoring;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
*: Datenbank, Cache, Queue-Rückstau,
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
* große Tabellen, damit `/health` selbst keine Last erzeugt.
*
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
*/
class HealthProbe
{
public const StatusOk = 'ok';
public const StatusWarn = 'warn';
public const StatusCritical = 'critical';
/**
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
*/
public static function heartbeatPath(): string
{
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
}
public function recordHeartbeat(): void
{
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
}
public function lastHeartbeat(): ?Carbon
{
$value = @file_get_contents(self::heartbeatPath());
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
}
/**
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
public function checks(): array
{
return [
$this->database(),
$this->cache(),
$this->queue(),
$this->scheduler(),
$this->disk(),
];
}
/**
* @param list<array{status: string}> $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];
}
}

View file

@ -0,0 +1,159 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
use Throwable;
/**
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
*
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
* in Query-Strings (signierte Links) und in Pfaden
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
* QueryException mit eingesetzten Bindings) ebenso.
*/
class ErrorReportScrubber
{
public const Filtered = '[Filtered]';
/**
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
*
* @var list<string>
*/
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
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<string, mixed> $request
* @return array<string, mixed>
*/
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;
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Sentry\Severity;
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
/**
* Fehler ohne Exception als GlitchTip-Warnung: tote
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
*/
class ErrorSignal
{
public const DeadInternalLink = 'dead_internal_link';
public const AccessDenied = 'access_denied';
public const PageExpired = 'page_expired';
public const Throttled = 'throttled';
private const Labels = [
self::DeadInternalLink => '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<string, mixed> $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;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Support\Monitoring;
/**
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
* beim Deploy entsteht (`git describe --tags --always --dirty > 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;
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
/**
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
* immer; Mail und ntfy-Push 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
{
/**
* @param array<string, mixed> $context
*/
public static function send(string $summary, array $context = []): void
{
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
if (! config('monitoring.alerts.enabled')) {
return;
}
self::mail($summary, $context);
self::push($summary, $context);
}
/**
* @param array<string, mixed> $context
*/
private static function mail(string $summary, array $context): void
{
$recipient = config('monitoring.alerts.mail_to');
if (empty($recipient)) {
return;
}
try {
Mail::raw(
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
);
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
/**
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
*
* @param array<string, mixed> $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(),
]);
}
}
}

View file

@ -1,14 +1,25 @@
<?php
use App\Http\Controllers\HealthController;
use App\Http\Middleware\ReportErrorSignals;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/domains.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
Route::get('/health', HealthController::class)
->middleware('throttle:30,1')
->name('health.deep');
},
)
->withMiddleware(function (Middleware $middleware) {
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
@ -27,11 +38,20 @@ return Application::configure(basePath: dirname(__DIR__))
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
]);
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
// global, weil 404 vor dem Routing entsteht
$middleware->append(ReportErrorSignals::class);
// BasicAuth ganz am Ende, nach Session-Middleware
if (env('BASIC_AUTH_ENABLED', true)) {
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
}
})
->withExceptions(function (Exceptions $exceptions) {
//
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
// Laravels dontReport (404, Validierung, Auth) gilt weiter
Integration::handles($exceptions);
// Vertippte artisan-Befehle sind kein Anwendungsfehler
$exceptions->dontReport(CommandNotFoundException::class);
})->create();

View file

@ -22,6 +22,7 @@
"livewire/flux": "^2.1.1",
"livewire/flux-pro": "^2.6",
"livewire/volt": "^1.7.0",
"sentry/sentry-laravel": "^4.29",
"spatie/laravel-permission": "^6.17"
},
"require-dev": {

553
composer.lock generated
View file

@ -4,11 +4,11 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9e07d437a063526b426b920d400db8b8",
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
"packages": [
{
"name": "acme/contact-form",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/acme/contact-form",
@ -55,7 +55,7 @@
},
{
"name": "acme/cookie-consent",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/acme/CookieConsent",
@ -860,7 +860,7 @@
},
{
"name": "flux-cms/core",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/flux-cms/core",
@ -1603,6 +1603,66 @@
],
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.35.0",
@ -3493,6 +3553,84 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
@ -4378,6 +4516,195 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sentry/sentry",
"version": "4.34.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/guzzle": "^7.0|^8.0",
"guzzlehttp/promises": "^2.0.3|^3.0.0",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.1",
"open-telemetry/context": "^1.1",
"open-telemetry/exporter-otlp": "^1.1",
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
"open-telemetry/sdk": "^1.1",
"open-telemetry/sem-conv": "^1.27",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T11:25:58+00:00"
},
{
"name": "sentry/sentry-laravel",
"version": "4.29.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
"sentry/sentry": "^4.34.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/ai": "^0.1.5 | ^1.0",
"laravel/folio": "^1.1",
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"laravel/octane": "^2.15",
"laravel/pennant": "^1.0",
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10 | ^2.2",
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Sentry": "Sentry\\Laravel\\Facade"
},
"providers": [
"Sentry\\Laravel\\ServiceProvider",
"Sentry\\Laravel\\Tracing\\ServiceProvider"
]
}
},
"autoload": {
"psr-0": {
"Sentry\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Laravel SDK for Sentry (https://sentry.io)",
"homepage": "https://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"laravel",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T12:31:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.0",
@ -5601,6 +5928,77 @@
],
"time": "2026-02-05T15:57:06+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
@ -6495,6 +6893,93 @@
],
"time": "2026-01-26T15:07:59+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.6",
@ -7967,66 +8452,6 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/boost",
"version": "v1.8.11",

View file

@ -15,6 +15,18 @@ return [
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Ausgelieferte Version
|--------------------------------------------------------------------------
|
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
*/
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
/*
|--------------------------------------------------------------------------
| Application Environment

View file

@ -52,6 +52,13 @@ return [
'channels' => [
'monitoring' => [
'driver' => 'daily',
'path' => storage_path('logs/monitoring.log'),
'level' => 'debug',
'days' => 14,
],
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),

75
config/monitoring.php Normal file
View file

@ -0,0 +1,75 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Betriebs-Alerts
|--------------------------------------------------------------------------
|
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
*/
'alerts' => [
'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true),
// Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem).
'mail_to' => env('MONITORING_ALERT_EMAIL'),
// Zweiter Alarmweg: 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
|--------------------------------------------------------------------------
|
| 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
|--------------------------------------------------------------------------
|
| `/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' => [
// Leer = storage/framework/scheduler-heartbeat. Die Tests setzen je Prozess eine eigene Datei.
'heartbeat_path' => env('MONITORING_HEARTBEAT_PATH'),
'heartbeat_warn_minutes' => 3,
'heartbeat_critical_minutes' => 10,
],
];

180
config/sentry.php Normal file
View file

@ -0,0 +1,180 @@
<?php
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\Release;
/**
* Sentry Laravel SDK configuration file.
*
* Ziel ist das selbst gehostete GlitchTip, das das
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
* Paket-Standard: kein Personenbezug außer der User-ID, keine
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
'dsn' => 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),
],
];

View file

@ -17,6 +17,10 @@ use Illuminate\Database\Seeder;
* Gesetzt wird das Logo passend zum Theme des Moduls (dunkel = negativ/weiss, hell = positiv)
* sowie der neue Claim. Alle anderen Einstellungen bleiben unangetastet.
*
* Module, die schon eines der beiden Interior-Logos tragen, bleiben ganz unberührt (08.10.2026):
* Sie sind entweder neu angelegt (Standardwerte) oder bereits umgestellt, und ein inzwischen im
* Display-CMS gepflegter Claim soll beim Livegang nicht zurückgesetzt werden.
*
* php artisan db:seed --class=DisplayInteriorBrandingSeeder
*/
class DisplayInteriorBrandingSeeder extends Seeder
@ -34,6 +38,10 @@ class DisplayInteriorBrandingSeeder extends Seeder
foreach (DisplayVersion::query()->where('type', DisplayVersionType::B2in)->get() as $module) {
$settings = $module->settings ?? [];
if (in_array($settings['header_logo_url'] ?? null, [self::LOGO_DARK, self::LOGO_LIGHT], true)) {
continue;
}
$settings['header_logo_url'] = ($settings['theme'] ?? 'dark') === 'light'
? self::LOGO_LIGHT
: self::LOGO_DARK;

View file

@ -0,0 +1,69 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
/**
* Alle Inhaltsschritte des Interior-Relaunchs in einem Lauf (08.10.2026).
*
* Bringt eine Datenbank mit dem alten Website-Stand auf den Stand der Vorschau: zuerst auf der
* Vorschau mit frischer Kopie der Live-Datenbank (Generalprobe), nach der Freigabe identisch auf
* Live. Displays, Playlists, Display-Mediathek und hochgeladene Medien bleiben, wie sie sind –
* angefasst werden nur das Display-Branding (nur Module ohne Interior-Logo) und absolute
* Medien-URLs (werden host-unabhängig).
*
* Reihenfolge wie Abschnitt 8.2 und „Seeder für das nächste Deployment“ im Entwicklungsplan.
* Jeder Schritt ist idempotent, ein zweiter Lauf ändert nichts mehr. Ausgeführt von
* `dev/betrieb/deploy.sh vX.Y.Z --migrations --seed=InteriorReleaseSeeder` im Wartungsmodus.
*
* php artisan db:seed --class=InteriorReleaseSeeder --force
*/
class InteriorReleaseSeeder extends Seeder
{
/**
* @var list<class-string<Seeder>>
*/
public const STEPS = [
// Fehlende CMS-Einträge aus den Lang-Dateien anlegen – vorhandene bleiben unberührt.
CmsContentSeeder::class,
CmsBrandSeeder::class,
// Die in AP1 neu getexteten Sektionen überschreiben (alter Website-Stand).
InteriorRelaunchCmsSeeder::class,
CmsArticleSeeder::class,
// Displays: nur Branding und Medien-URLs.
DisplayInteriorBrandingSeeder::class,
DisplayMediaUrlNormalizerSeeder::class,
// Marken
BrandPageTextSeeder::class,
BrandGalleryCaptionNormalizerSeeder::class,
WorldSplitCmsSeeder::class,
OriginSectionCmsSeeder::class,
BrandOriginSeeder::class,
OtterpohlBrandSeeder::class,
BrandImageSeeder::class,
DemoBrandGalleryCleanupSeeder::class,
// Texte und Korrekturen
WhatsappNumberSeeder::class,
AudienceSwitchProjectTextSeeder::class,
HomeHeroTextSeeder::class,
UnusedCmsContentCleanupSeeder::class,
AboutPageTextSeeder::class,
ProofreadingCorrectionsSeeder::class,
LegalTextUpdateSeeder::class,
ReferencesPageSwitchSeeder::class,
HomeHeroSlidesSeeder::class,
PageHeroEyebrowSeeder::class,
HeroTrustItemsSeeder::class,
HomeVisionTitleSeeder::class,
ContentCorrections20261008Seeder::class,
// Zuletzt: fehlende Felder ergänzen, verwendete Bilder in die Mediathek.
CmsSectionFieldsSeeder::class,
AssetMediaImportSeeder::class,
];
public function run(): void
{
$this->call(self::STEPS);
}
}

View file

@ -2492,3 +2492,28 @@ korrigiert – u. a. Hinweisleiste (über B2IN_ANNOUNCEMENT_BAR aus, Feld „ena
**Nachbesserung 08.10.:** Hero mobil – die Infokarte steht unter dem Bild (Bild 4:3 oben, Karte als weißer
Streifen darunter), ab lg schwebt sie wie bisher unten links. Karte weiß statt `bg-card`, damit die
Glass-Flow-Regel `[class*="card"]` nicht greift; `absolute!` entfällt.
---
## Nachtrag 32 · Git-Deploy, Monitoring und Ablauf für den Livegang (08.10.2026)
- **Git-Deploy per Tag:** `dev/betrieb/deploy.sh` (aus `dev/git-deploy-monitoring/`) liefert nur Tags aus,
`origin` = forge.adametz.media, `NAS` = Sicherung. `public/build` ist jetzt versioniert. Neu:
`--seed=<Klasse>` (Seeder im Wartungsmodus) und `--initial` (Vorschau und Live einmalig an Ort und
Stelle auf Git umstellen, mit Code-Sicherung und Parken der Nur-Server-Dateien).
`.gitignore`: Das macOS-Muster `Icon` traf `resources/views/flux/icon/`, die vier Icons wären mit
Git-Deploy auf Live verloren gegangen; das Muster heißt jetzt `Icon[\r]`.
- **Monitoring:** GlitchTip (Server und Browser, Website und Portal), `/health` mit Token (jede Domain,
ohne Basic Auth), Scheduler-Herzschlag, Alarme bei fehlgeschlagenen Tasks per Mail und ntfy,
Version in `php artisan about` und `/health`. Pakete: `sentry/sentry-laravel`, `@sentry/browser`.
- **Livegang ohne Datenmigration:** Die Live-Datenbank bleibt die Grundlage (Displays, Playlists,
Mediathek, Benutzer), `InteriorReleaseSeeder` bringt die Website-Inhalte in einem Lauf auf den neuen
Stand. Er fasst alle Seeder aus 8.2 und „Seeder für das nächste Deployment“ zusammen, ist idempotent
(Test) und lässt Display-Daten unberührt. Ausnahme: Branding, nur bei Modulen ohne Interior-Logo;
`DisplayInteriorBrandingSeeder` überschreibt seit heute keinen inzwischen gepflegten Claim mehr.
Generalprobe: `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau.
- **Ablauf:** `dev/betrieb/README.md`. Er ersetzt für den Livegang die Einzelbefehle aus 8.2 und die
Seeder-Liste oben. Die Prüfliste 8.3 gilt weiter.
- **Offen:** Datenschutzerklärung um die Fehlerprotokollierung ergänzen, bevor die DSNs auf Live gesetzt
werden (Textvorschlag in `dev/betrieb/README.md`, Abschnitt 6).

235
dev/betrieb/README.md Normal file
View file

@ -0,0 +1,235 @@
# Betrieb B2in: Git-Deploy, Livegang, Monitoring
**Stand:** 08.10.2026. Grundlage ist der Baukasten unter `dev/git-deploy-monitoring/`. Dort steht,
warum etwas so gebaut ist. Hier steht, wie es für B2in läuft.
| Was | Wo |
|---|---|
| Deploy-Skript | `dev/betrieb/deploy.sh`, liefert nur Git-Tags `vX.Y.Z` aus |
| Vorschau auffrischen | `dev/betrieb/vorschau-auffrischen.sh` kopiert Live-DB und Live-Medien in die Vorschau |
| Inhaltsschritte Relaunch | `InteriorReleaseSeeder`, alle 29 Seeder in einem Lauf, idempotent |
| Remote `origin` | lokal `ssh://git@forge.adametz.media:222/kadmin/b2in.git`, auf dem Server `forge-b2in:kadmin/b2in.git` (Host-Alias, Abschnitt 2): Deploy-Quelle für Vorschau und Live |
| Remote `NAS` | `ssh://git@git.adametz.media:222/gitmedia/b2in.git`: lokale Sicherung, wird nie deployt |
| Fehlerprotokoll | GlitchTip `https://errors.adametz.media`, Organisation `adametz-media`, Projekte `server-b2in` (ID 8) und `browser-b2in` (ID 7) |
| Push-Alarme | ntfy `https://push.adametz.media`, Topic `b2in-ops` |
| Externer Monitor | Uptime Kuma auf `/health` (mit Token) und `/up` je Domain |
---
## 1. Release lokal (jedes Mal)
```bash
git status # nichts offen
php artisan test --compact
npm ci && npm run build:web && npm run build:portal # public/build ist versioniert, der Server hat kein Node
git add public/build && git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z"
git tag -a vX.Y.Z -m "vX.Y.Z – <Inhalt>
Deploy: <keine Migration | N Migrationen>, <Seeder>, <neue .env-Werte>
bash dev/betrieb/deploy.sh vX.Y.Z [--migrations] [--seed=…]"
git push origin <branch> --follow-tags
git push NAS <branch> --follow-tags # Sicherung
```
Versionen folgen SemVer: Ein Feature-Release erhöht die mittlere Stelle, ein Hotfix die letzte.
Der Interior-Relaunch ist `v2.0.0`.
---
## 2. Einmalig auf dem Server
Als App-Benutzer, gilt für Vorschau und Live (gleicher Server):
1. **Deploy-Key**, nur Leserecht am Repository auf forge.adametz.media. Auf dem Server liegen mehrere
Deploy-Keys für denselben Forgejo (B2in, Breco), deshalb gibt es je Projekt einen Host-Alias.
Die Repository-Adresse heißt dann **`forge-b2in:kadmin/b2in.git`**, nicht `ssh://git@forge.adametz.media:222/…`;
mit der vollen Adresse greift kein Alias, und ssh bietet keinen Schlüssel an („Permission denied (publickey)“).
```bash
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2in -N "" -C "b2in-server"
cat ~/.ssh/deploy_b2in.pub # in Forgejo: kadmin/b2in ▸ Einstellungen ▸ Deploy-Schlüssel, ohne Schreibrecht
```
`~/.ssh/config`:
```
Host forge-b2in
HostName forge.adametz.media
Port 222
User git
IdentityFile ~/.ssh/deploy_b2in
IdentitiesOnly yes
HostKeyAlias forge.adametz.media
```
Test: `git ls-remote forge-b2in:kadmin/b2in.git` zeigt die Tags.
2. **Flux Pro für Composer:** `deploy.sh` führt `composer install` aus. Dafür braucht der Server die
Flux-Zugangsdaten, also `auth.json` im App-Verzeichnis (ignoriert, bleibt liegen) oder
`composer config --global http-basic.composer.fluxui.dev <e-mail> <lizenz>`.
3. **PHP-FPM-Reload ohne Passwort testen:** `echo "" | sudo -S service php8.4-fpm reload; echo $?`.
Ergibt das Exit 0, lädt das Skript FPM selbst neu. Fragt sudo nach einem Passwort, beim Deploy
`DEPLOY_FPM_RELOAD=` setzen; dann wartet das Skript, bis FPM im Panel neu geladen ist. Heißt
der Dienst anders: `DEPLOY_PHP_FPM_SERVICE=…`. **Immer Reload, nie Restart.**
4. **Cron für den Scheduler:** `* * * * * cd <app> && php artisan schedule:run >> /dev/null 2>&1`.
Er trägt den Herzschlag für `/health` und `cabinet:reset-overrides`. Eine Queue braucht B2in
nicht; der Worker-Test im Skript ist aus.
5. **Server-Panel:** „Quick Deploy“ und Push-Webhooks bleiben aus. Ein Push ist noch kein Release.
### `.env`-Werte je Umgebung
| Schlüssel | Live | Vorschau |
|---|---|---|
| `SENTRY_LARAVEL_DSN`, `SENTRY_BROWSER_DSN` | wie lokal (Projekte server-b2in / browser-b2in), **erst nach der Datenschutzerklärung (Abschnitt 6)** | gleich, optional |
| `SENTRY_ENVIRONMENT` | `production` | `preview` |
| `MONITORING_NTFY_URL`, `_TOPIC`, `_TOKEN` | wie lokal | leer (keine Push-Alarme aus der Vorschau) |
| `MONITORING_ALERT_EMAIL` | Ops-Postfach | leer |
| `MONITORING_HEALTH_TOKEN` | **eigener Wert** (`openssl rand -hex 24`), nicht der lokale | eigener Wert oder leer |
| `MAIL_MAILER` | wie bisher | `log` (die Vorschau hat Live-Daten) |
`APP_VERSION` bleibt leer. Die Version kommt aus der Datei `RELEASE`, die das Skript schreibt.
---
## 3. Livegang Interior-Relaunch (v2.0.0)
**Grundidee:** Auf Live hat der Kunde Displays, Playlists und die Mediathek gepflegt, an der Website
im CMS aber nichts geändert. Deshalb kopieren wir **nicht** die Vorschau-Datenbank nach Live und
übertragen Display-Daten einzeln. Stattdessen bleibt die Live-Datenbank die Grundlage, und
`InteriorReleaseSeeder` bringt deren Website-Inhalte auf den neuen Stand. Displays,
Display-Mediathek, CMS-Mediathek, Benutzer und hochgeladene Dateien bleiben unberührt. Angepasst
werden nur:
- das Display-Branding, und zwar nur bei Modulen, die noch kein Interior-Logo tragen
- absolute Medien-URLs, die auf `b2in.test` oder `b2in.eu` zeigen; sie werden host-unabhängig
Damit der Kunde genau dieses Ergebnis vorher sieht, läuft derselbe Ablauf zuerst auf der Vorschau
mit einer frischen Kopie der Live-Daten (Generalprobe).
### 3.1 Generalprobe auf der Vorschau
```bash
# Skripte aus dem Tag holen (Vorschau und Live sind noch keine Git-Checkouts)
git clone -q --depth 1 --branch v2.0.0 forge-b2in:kadmin/b2in.git /tmp/b2in-v2.0.0
# 1. Live-Daten und Live-Medien in die Vorschau (Live wird nur gelesen; legt ~/b2in-live-<zeit>.sql.gz an)
bash /tmp/b2in-v2.0.0/dev/betrieb/vorschau-auffrischen.sh <live> <vorschau>
# 2. Vorschau einmalig auf Git umstellen und v2.0.0 ausliefern, mit allen Inhaltsschritten
cd <vorschau>
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
```
`--initial` sichert vorher den Code nach `~/<verzeichnis>-vor-git-<zeit>.tgz`. Dann zeigt es die
Dateien, die es nur auf dem Server gibt (Reste aus SFTP- und Archiv-Updates). Erst nach einem
`ja` verschiebt es sie nach `~/<verzeichnis>-vor-git-<zeit>/`. Danach ist `git status` leer.
Vorschau prüfen: die Liste unter 8.3 im Entwicklungsplan, dazu im Portal der Vorschau die
**Displays und Playlists mit Live vergleichen**. Dort müssen dieselben Module, Bilder und Videos
erscheinen. Neue Generalprobe, etwa nach einem Hotfix-Tag oder weil auf Live weiter gepflegt
wurde:
```bash
cd <vorschau> && bash dev/betrieb/deploy.sh v2.0.1 --migrations # nur bei neuem Tag
bash dev/betrieb/vorschau-auffrischen.sh <live> <vorschau> --seed=InteriorReleaseSeeder
```
### 3.2 Livegang nach der Freigabe
1. Wartungsfenster mit Marcel abstimmen. Die Displays holen ihre Konfiguration zyklisch, laufen
also weiter, ein Wechsel im Laden ist aber sichtbar.
2. **DB-Backup von Live:**
`mysqldump --single-transaction --no-tablespaces -u … -p b2in_live | gzip > ~/b2in-live-vor-v2.sql.gz`
3. Live-`.env` ergänzen (Tabelle oben). `SENTRY_*` erst nach Abschnitt 6.
4. Gleicher Befehl wie auf der Vorschau, nur im Live-Verzeichnis:
```bash
cd <live>
bash /tmp/b2in-v2.0.0/dev/betrieb/deploy.sh v2.0.0 --initial --migrations --seed=InteriorReleaseSeeder
rm -rf /tmp/b2in-v2.0.0
```
5. Prüfen: `php artisan about --only=b2in` zeigt `v2.0.0`. `/health` mit Token liefert
`"status":"ok"` (der Herzschlag braucht nach dem Deploy bis zu einer Minute). Danach die
Liste 8.3 im Entwicklungsplan, die vier Displays sichten, `storage/logs` und GlitchTip auf neue
Fehler prüfen.
**Rückweg** (nur Live, falls etwas grundlegend nicht passt): Seite in den Wartungsmodus nehmen,
`.git` entfernen, die Code-Sicherung `~/<live>-vor-git-<zeit>.tgz` zurückspielen, das DB-Backup
aus Schritt 2 einspielen und FPM neu laden. Die geparkten Nur-Server-Dateien aus
`~/<live>-vor-git-<zeit>/` liegen ebenfalls noch vor.
---
## 4. Folge-Deploys (ab v2.0.0)
```bash
cd <app>
bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen
bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen, vorher DB-Backup
bash dev/betrieb/deploy.sh vX.Y.Z --seed=<Seeder> # Inhaltsschritt laut Release-Notiz
bash dev/betrieb/deploy.sh <vorheriger Tag> # Rollback
```
Das Skript bricht ab, **bevor** es etwas ändert, wenn:
- im Verzeichnis jemand von Hand etwas geändert hat
- der Tag fehlt
- ein Build-Manifest fehlt
- neue Migrationen ohne `--migrations` kommen
Auf dem Server wird nichts von Hand geändert, auch Hotfixes laufen über Commit, Tag und Deploy.
Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf.
---
## 5. Monitoring
Eingebaut nach `dev/git-deploy-monitoring/` (02 und 03), Tests unter `tests/Feature/Monitoring/`.
| Ausfall | Wer meldet | Kanal |
|---|---|---|
| Exception (Request, Command, Livewire) | Sentry-SDK | GlitchTip `server-b2in` |
| JavaScript-Fehler (Website und Portal) | `resources/js/error-reporting.js` | GlitchTip `browser-b2in` (Tag `area`: `web`/`portal`) |
| Toter interner Link, 419, 403/429 für Angemeldete | `ReportErrorSignals` | GlitchTip (Warnung) |
| Geplanter Task fehlgeschlagen | `onFailure` in `routes/console.php` | Log `monitoring`, Mail, ntfy |
| Cron steht, DB/Cache weg, Platte voll | `/health` | Uptime Kuma, dann ntfy und Mail |
| Server, FPM, Nginx oder SSL weg | `/up` nicht erreichbar | Uptime Kuma |
**Uptime Kuma** (status.adametz.media):
| Monitor | Ziel | Intervall |
|---|---|---|
| B2in /health | `https://b2in.eu/health`, Header `{"Authorization": "Bearer <MONITORING_HEALTH_TOKEN von Live>"}`, Status 200–299 | 60 s, 2 Wiederholungen |
| B2in /up | `https://b2in.eu/up` und `https://portal.b2in.eu/up` | 5 min |
| Zertifikate | Ablaufwarnung 14 Tage vorher | – |
`/health` antwortet auf jeder Domain, ist von der Basic Auth ausgenommen und liefert ohne oder
mit falschem Token 404.
**Abnahme auf Live:**
- `php artisan tinker --execute 'App\Support\OperationalAlert::send("Test B2in");'`: Push und Mail kommen an
- `php artisan tinker --execute 'report(new RuntimeException("Test B2in"));'`: erscheint in `server-b2in`, Umgebung `production`, Release `v2.0.0`
- Browser-Konsole auf b2in.eu: `setTimeout(() => { throw new Error('Test test@example.com') })` erscheint in `browser-b2in`, die E-Mail als `[email]`
- Uptime Kuma: Ein Test-Monitor mit falschem Token löst einen Alarm aus, danach löschen
**GlitchTip auswerten** (lokal, Lese-Token `GLITCHTIP_API_TOKEN` in der `.env`): Die Befehle stehen
in `dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`, Abschnitt 7, mit
`B=https://errors.adametz.media/api/0`, `ORG=adametz-media`, Projekt `8` (Server) bzw. `7` (Browser).
Lokal sind die DSNs gesetzt (`SENTRY_ENVIRONMENT=local`). Fehler beim Entwickeln landen also in
GlitchTip, aber getrennt nach Umgebung. Die Testsuite meldet nie etwas; `phpunit.xml` leert DSNs
und ntfy.
---
## 6. Datenschutzerklärung (vor dem Einschalten der DSNs auf Live)
Die Fehlerprotokollierung muss in die Datenschutzerklärung, bevor auf Live `SENTRY_*` gesetzt wird.
Hier ein Textvorschlag, den Marcel bzw. die Rechtsberatung freigeben muss:
> **Fehlerprotokollierung.** Tritt beim Aufruf unserer Website ein technischer Fehler auf,
> speichern wir dazu die aufgerufene Adresse (ohne Formularinhalte und ohne Zugangs-Token), Browser
> und Betriebssystem, die Fehlermeldung und, bei angemeldeten Nutzern, deren interne Nutzer-ID.
> IP-Adressen und Cookies werden nicht übermittelt. Die Daten verarbeiten wir auf einem eigenen
> Server in Deutschland, um Fehler zu erkennen und zu beheben. Rechtsgrundlage ist unser
> berechtigtes Interesse am sicheren und fehlerfreien Betrieb (Art. 6 Abs. 1 lit. f DSGVO). Die
> Einträge werden nach 90 Tagen gelöscht.
Vor der Freigabe prüfen: Serverstandort und Aufbewahrungsfrist (`GLITCHTIP_RETENTION_DAYS`).
Eingetragen wird der Text in `resources/lang/de/b2in_legal.php` und im CMS unter „Rechtliches“.

294
dev/betrieb/deploy.sh Executable file
View file

@ -0,0 +1,294 @@
#!/usr/bin/env bash
#
# Deploy eines Git-Tags auf Vorschau oder Live (B2in). Grundlage: Baukasten
# dev/git-deploy-monitoring, Ablauf für B2in: dev/betrieb/README.md.
#
# Aufruf im App-Verzeichnis auf dem Server:
#
# bash dev/betrieb/deploy.sh v2.0.0 # ohne neue Migrationen
# bash dev/betrieb/deploy.sh v2.0.0 --migrations # mit Migrationen, DB-Backup vorher!
# bash dev/betrieb/deploy.sh v2.0.0 --migrations --seed=InteriorReleaseSeeder
#
# Optionen:
# --migrations Neue Migrationen ausführen. Ohne diese Option bricht das
# Skript ab, sobald der Tag neue Migrationen mitbringt.
# --clear-schedule php artisan schedule:clear-cache (alte withoutOverlapping-Sperren).
# --seed=<Klasse> Seeder im Wartungsmodus ausführen (nach den Migrationen,
# vor den Caches) – für Inhaltsschritte, die die Release-Notiz nennt.
# --initial Einmalig: ein Verzeichnis ohne Git (bisher SFTP/Archiv) an Ort und
# Stelle zum Checkout des Tags machen. Sichert vorher den Code nach
# ~/<verzeichnis>-vor-git-<zeit>.tgz, zeigt Dateien, die es nur auf dem
# Server gibt, und verschiebt sie nach Rückfrage nach ~/<verzeichnis>-vor-git-<zeit>/.
# Verlangt --migrations (vorher DB-Backup).
#
# Umgebung (optional):
# DEPLOY_FPM_RELOAD Befehl zum Neuladen von PHP-FPM (Standard siehe unten).
# Leer setzen, wenn FPM von Hand im Server-Panel neu geladen
# wird — dann wartet das Skript auf Enter. Nie „Restart“:
# der entfernt kurz den Socket, Nginx liefert 502.
# DEPLOY_PHP_FPM_SERVICE systemd-Name des FPM-Dienstes (Standard php8.4-fpm).
# DEPLOY_REPOSITORY Repository für --initial (Standard: Host-Alias forge-b2in aus ~/.ssh/config).
#
# Exit-Codes: 0 fertig, 1 abgebrochen (siehe Ausgabe), 2 falscher Aufruf,
# 3 live, aber kein Queue-Worker hat den Test-Job abgeholt.
#
# Rollback: dasselbe Skript mit dem vorherigen Tag. Nach --initial: Sicherung
# zurückspielen (Pfad steht in der Ausgabe) und DB-Backup einspielen.
set -euo pipefail
# ---- Projekt-Einstellungen --------------------------------------------------
DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad dieses Skripts im Repository
PHP_FPM_SERVICE="${DEPLOY_PHP_FPM_SERVICE:-php8.4-fpm}" # systemd-Name des FPM-Dienstes
ABOUT_SECTION="b2in" # AboutCommand::add('B2in', …) im AppServiceProvider
QUEUE_PROBE=0 # B2in stellt keine Jobs in die Queue (Mails gehen sofort raus)
REPOSITORY="${DEPLOY_REPOSITORY:-forge-b2in:kadmin/b2in.git}"
# -----------------------------------------------------------------------------
# Git ersetzt diese Datei beim Checkout. Bash liest Skripte stückweise, deshalb
# läuft der Deploy aus einer Kopie.
if [[ -z "${DEPLOY_COPY:-}" ]]; then
copy="$(mktemp)"
cp "$0" "$copy"
DEPLOY_COPY="$copy" exec bash "$copy" "$@"
fi
trap 'rm -f "$DEPLOY_COPY"' EXIT
tag="${1:-}"
shift || true
run_migrations=0
clear_schedule=0
initial=0
seeders=()
for option in "$@"; do
case "$option" in
--migrations) run_migrations=1 ;;
--clear-schedule) clear_schedule=1 ;;
--initial) initial=1 ;;
--seed=*)
seeder="${option#--seed=}"
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
echo "Ungültiger Seeder: $seeder" >&2; exit 2
fi
seeders+=("$seeder") ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Aufruf: bash $DEPLOY_SCRIPT vX.Y.Z [--migrations] [--seed=<Klasse>] [--clear-schedule] [--initial]" >&2
exit 2
fi
if [[ ! -f artisan ]]; then
echo "Im App-Verzeichnis aufrufen (artisan fehlt hier)." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
if [[ "$initial" -eq 1 ]]; then
if [[ -d .git ]]; then
echo "--initial nur für ein Verzeichnis ohne Git – hier gibt es .git schon." >&2
exit 2
fi
if [[ "$run_migrations" -ne 1 ]]; then
echo "--initial verlangt --migrations (vorher DB-Backup ziehen)." >&2
exit 2
fi
stamp="$(date +%Y-%m-%d-%H%M)"
site="$(basename "$PWD")"
backup="$HOME/$site-vor-git-$stamp.tgz"
parked="$HOME/$site-vor-git-$stamp"
step "Sicherung des Codes nach $backup (ohne storage/, node_modules/)"
# Exit 1 = „Datei hat sich beim Lesen geändert“ (Logs o. Ä.) – die Sicherung ist trotzdem brauchbar.
tar czf "$backup" --exclude=./storage --exclude=./node_modules --exclude=./public/storage . || [[ $? -eq 1 ]]
ls -lh "$backup"
step "Git einrichten, Tag $tag holen"
git init -q
git remote add origin "$REPOSITORY"
if ! git fetch --quiet --tags origin || ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
rm -rf .git
echo "Tag $tag nicht abrufbar (Deploy-Key? Tag gepusht?). .git wieder entfernt, nichts verändert." >&2
exit 1
fi
git update-ref --no-deref HEAD "$(git rev-parse "$tag^{commit}")"
git read-tree HEAD
# Ignorier-Regeln des Tags gelten schon jetzt (die .gitignore im Verzeichnis ist noch die alte).
git show "$tag:.gitignore" > .git/info/exclude
changed="$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ')"
server_only="$(git -c core.quotePath=false ls-files --others --exclude-standard)"
echo "$changed Dateien weichen vom Tag ab oder fehlen – der Checkout ersetzt sie."
if [[ -n "$server_only" ]]; then
echo
echo "Diese $(echo "$server_only" | wc -l | tr -d ' ') Dateien gibt es nur auf dem Server (nicht im Tag, nicht ignoriert):"
echo "$server_only" | sed 's/^/ /' | head -200
echo
echo "Sie werden nach $parked/ verschoben (Pfade bleiben erhalten). Wird eine davon gebraucht,"
echo "jetzt abbrechen und sie ins Repository oder in die .gitignore aufnehmen (neuer Tag)."
fi
read -r -p "Weiter mit dem Deploy von $tag? [ja/nein] " answer
if [[ "$answer" != "ja" ]]; then
rm -rf .git
echo "Abgebrochen. .git wieder entfernt, nichts verändert (Sicherung bleibt liegen)." >&2
exit 1
fi
previous="vor-git (Sicherung: $backup)"
new_migrations="(Umstellung: alle offenen Migrationen)"
else
if [[ ! -d .git ]]; then
echo "Hier gibt es kein .git – für die einmalige Umstellung --initial verwenden." >&2
exit 2
fi
step "Arbeitsverzeichnis prüfen"
drift="$(git status --porcelain)"
if [[ -n "$drift" ]]; then
echo "Das Arbeitsverzeichnis weicht vom ausgecheckten Stand ab:" >&2
echo "$drift" >&2
echo "Erst klären (git diff), dann deployen. Nichts wurde verändert." >&2
exit 1
fi
step "Tags von origin holen"
git fetch --quiet --tags origin
if ! git rev-parse --quiet --verify "refs/tags/$tag^{commit}" > /dev/null; then
echo "Tag $tag gibt es auf origin nicht." >&2
exit 1
fi
previous="$(cat RELEASE 2>/dev/null || git describe --tags --always)"
if [[ "$(git rev-parse HEAD)" == "$(git rev-parse "$tag^{commit}")" ]]; then
echo "$tag ist bereits ausgecheckt. Nichts zu tun."
exit 0
fi
fi
echo "Bisher: $previous → Ziel: $tag"
for build in b2in b2a stileigentum style2own portal; do
if ! git cat-file -e "$tag:public/build/$build/manifest.json" 2> /dev/null; then
echo "public/build/$build/manifest.json fehlt im Tag $tag – Assets lokal bauen und committen. Nichts wurde verändert." >&2
exit 1
fi
done
if [[ "$initial" -ne 1 ]]; then
new_migrations="$(git diff --name-only --diff-filter=A HEAD "$tag" -- database/migrations)"
fi
if [[ -n "$new_migrations" ]]; then
echo "Neue Migrationen:"
echo "$new_migrations" | sed 's/^/ /'
if [[ "$run_migrations" -ne 1 ]]; then
echo "Abbruch: DB-Backup ziehen und mit --migrations erneut aufrufen. Nichts wurde verändert." >&2
exit 1
fi
fi
if [[ "$initial" -eq 1 ]]; then
trap 'echo; echo "!! Umstellung abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rückweg: Verzeichnis aus $backup wiederherstellen (vorher .git entfernen), DB-Backup einspielen." >&2; rm -f "$DEPLOY_COPY"' ERR
else
trap 'echo; echo "!! Deploy abgebrochen. Die Seite bleibt im Wartungsmodus." >&2; echo "!! Rollback: bash $DEPLOY_SCRIPT $previous" >&2; rm -f "$DEPLOY_COPY"' ERR
fi
step "Wartungsmodus"
php artisan down --retry=60
step "Checkout $tag"
if [[ "$initial" -eq 1 ]]; then
if [[ -n "$server_only" ]]; then
while IFS= read -r file; do
mkdir -p "$parked/$(dirname "$file")"
mv "$file" "$parked/$file"
done <<< "$server_only"
echo "Nur-Server-Dateien verschoben nach $parked/"
fi
git checkout --quiet --force --detach "$tag"
: > .git/info/exclude
else
git checkout --quiet --detach "$tag"
fi
echo "$tag" > RELEASE
step "Composer"
composer install --no-dev --optimize-autoloader --no-interaction
if [[ -n "$new_migrations" ]]; then
step "Migrationen"
php artisan migrate --force
fi
for seeder in "${seeders[@]}"; do
step "Seeder $seeder"
php artisan db:seed --class="$seeder" --force
done
step "Öffentliche Assets"
if [[ ! -e public/storage ]]; then
php artisan storage:link
fi
if [[ -d public/vendor/livewire ]]; then
php artisan vendor:publish --tag=livewire:assets --force
fi
# Statisch ausgelieferte Flux-Skripte passend zur installierten Flux-Version
if [[ -d public/flux ]]; then
php artisan flux:publish-assets --force
fi
step "Caches"
php artisan config:cache
php artisan route:cache
php artisan view:clear
php artisan view:cache
php artisan event:cache
if [[ "$clear_schedule" -eq 1 ]]; then
php artisan schedule:clear-cache
fi
step "Worker und PHP-FPM"
php artisan queue:restart
default_fpm_reload="echo '' | sudo -S service $PHP_FPM_SERVICE reload"
fpm_reload="${DEPLOY_FPM_RELOAD-$default_fpm_reload}"
if [[ -n "$fpm_reload" ]]; then
bash -c "$fpm_reload"
else
echo "PHP-FPM jetzt im Server-Panel neu laden (Reload, nicht Restart), danach Enter."
read -r
fi
step "Wartungsmodus aus"
php artisan up
step "Version"
if [[ -n "$ABOUT_SECTION" ]]; then
php artisan about --only="$ABOUT_SECTION"
else
cat RELEASE
fi
trap - ERR
# Läuft ein Worker? `queue:restart` oben beendet ihn; neu starten muss ihn
# Supervisor. Läuft Supervisor nicht, steht die Queue still, ohne dass es
# jemand merkt.
if [[ "$QUEUE_PROBE" -eq 1 ]]; then
step "Queue-Worker"
if ! php artisan monitoring:queue-probe --wait=60; then
echo
echo "!! Deploy $tag ist live, aber KEIN Queue-Worker läuft." >&2
echo "!! Jetzt Supervisor und den Worker im Server-Panel prüfen — sonst bleiben Jobs und Mails liegen." >&2
exit 3
fi
fi
echo
echo "Deploy $tag fertig (vorher $previous). Weiter mit den Prüfungen aus der Deploy-Anleitung."

View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
#
# Vorschau mit den aktuellen Live-Daten auffrischen (B2in). Ablauf: dev/betrieb/README.md.
#
# Kopiert die komplette Live-Datenbank und die hochgeladenen Medien (storage/app/public:
# Display-Mediathek, CMS-Mediathek, Videos, QR-Codes) in die Vorschau. Danach steht auf der
# Vorschau genau das, was der Kunde auf Live gepflegt hat – Displays, Playlists, Mediathek –,
# und der Release-Seeder kann darauf zur Probe laufen.
#
# Live wird nur gelesen (mysqldump --single-transaction, rsync als Quelle). Die Vorschau-
# Datenbank wird vollständig ersetzt.
#
# Aufruf (auf dem Server, als App-Benutzer):
#
# bash dev/betrieb/vorschau-auffrischen.sh <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]
#
# --seed=<Klasse> nach dem Import (und den Migrationen) diesen Seeder auf der Vorschau ausführen,
# z. B. --seed=InteriorReleaseSeeder für eine erneute Generalprobe.
#
# Die Live-Sicherung bleibt als ~/b2in-live-<zeit>.sql.gz liegen.
set -euo pipefail
live="${1:-}"
preview="${2:-}"
shift 2 || true
seeders=()
for option in "$@"; do
case "$option" in
--seed=*)
seeder="${option#--seed=}"
if [[ ! "$seeder" =~ ^[A-Za-z][A-Za-z0-9]*$ ]]; then
echo "Ungültiger Seeder: $seeder" >&2; exit 2
fi
seeders+=("$seeder") ;;
*) echo "Unbekannte Option: $option" >&2; exit 2 ;;
esac
done
if [[ -z "$live" || -z "$preview" || ! -f "$live/artisan" || ! -f "$preview/artisan" ]]; then
echo "Aufruf: bash $0 <live-verzeichnis> <vorschau-verzeichnis> [--seed=<Klasse>]" >&2
exit 2
fi
live="$(cd "$live" && pwd)"
preview="$(cd "$preview" && pwd)"
if [[ "$live" == "$preview" ]]; then
echo "Live- und Vorschau-Verzeichnis sind dasselbe." >&2
exit 2
fi
step() { printf '\n==> %s\n' "$*"; }
# Wert aus einer .env lesen (ohne Anführungszeichen, ohne Kommentar am Zeilenende bei unquotierten Werten)
env_value() {
local file="$1" key="$2" line value
line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)"
value="${line#*=}"
if [[ "$value" =~ ^\"(.*)\"$ || "$value" =~ ^\'(.*)\'$ ]]; then
value="${BASH_REMATCH[1]}"
else
value="${value%% #*}"
fi
printf '%s' "$value"
}
# MySQL-Zugangsdaten als Datei (chmod 600), damit das Passwort nicht in der Prozessliste steht
credentials() {
local env="$1/.env" file
file="$(mktemp)"
chmod 600 "$file"
{
echo "[client]"
echo "host=$(env_value "$env" DB_HOST)"
echo "port=$(env_value "$env" DB_PORT)"
echo "user=$(env_value "$env" DB_USERNAME)"
echo "password=\"$(env_value "$env" DB_PASSWORD)\""
} > "$file"
printf '%s' "$file"
}
live_db="$(env_value "$live/.env" DB_DATABASE)"
preview_db="$(env_value "$preview/.env" DB_DATABASE)"
live_host="$(env_value "$live/.env" DB_HOST)"
preview_host="$(env_value "$preview/.env" DB_HOST)"
if [[ -z "$live_db" || -z "$preview_db" ]]; then
echo "DB_DATABASE fehlt in einer der beiden .env-Dateien." >&2
exit 1
fi
if [[ "$live_db" == "$preview_db" && "$live_host" == "$preview_host" ]]; then
echo "STOPP: Vorschau und Live zeigen auf dieselbe Datenbank ($live_db). Nichts wurde verändert." >&2
exit 1
fi
if [[ "$(env_value "$preview/.env" APP_ENV)" == "production" ]]; then
echo "STOPP: Die Vorschau läuft mit APP_ENV=production – ist das wirklich die Vorschau? Nichts wurde verändert." >&2
exit 1
fi
mailer="$(env_value "$preview/.env" MAIL_MAILER)"
if [[ "$mailer" != "log" && "$mailer" != "array" ]]; then
echo "Hinweis: MAIL_MAILER der Vorschau ist „$mailer“. Mit den Live-Daten könnte die Vorschau echte Mails verschicken."
echo "Empfohlen: MAIL_MAILER=log in $preview/.env."
fi
echo "Live: $live (Datenbank $live_db)"
echo "Vorschau: $preview (Datenbank $preview_db – wird vollständig ersetzt)"
echo "Medien: $live/storage/app/public/ → $preview/storage/app/public/ (Vorschau-eigene Dateien werden entfernt)"
read -r -p "Weiter? [ja/nein] " answer
if [[ "$answer" != "ja" ]]; then
echo "Abgebrochen, nichts verändert."
exit 1
fi
live_credentials="$(credentials "$live")"
preview_credentials="$(credentials "$preview")"
trap 'rm -f "$live_credentials" "$preview_credentials"' EXIT
dump="$HOME/b2in-live-$(date +%Y-%m-%d-%H%M).sql.gz"
step "Live-Datenbank sichern nach $dump"
mysqldump --defaults-extra-file="$live_credentials" --single-transaction --quick --no-tablespaces \
--routines --triggers --default-character-set=utf8mb4 "$live_db" | gzip > "$dump"
ls -lh "$dump"
step "Vorschau in den Wartungsmodus"
(cd "$preview" && php artisan down --retry=60)
trap 'echo; echo "!! Abgebrochen. Die Vorschau bleibt im Wartungsmodus; Live ist unverändert." >&2; rm -f "$live_credentials" "$preview_credentials"' ERR
step "Vorschau-Datenbank leeren und Live-Stand einspielen"
(cd "$preview" && php artisan db:wipe --force)
gunzip -c "$dump" | mysql --defaults-extra-file="$preview_credentials" --default-character-set=utf8mb4 "$preview_db"
# Sitzungen und Cache von Live gehören nicht in die Vorschau
for table in sessions cache cache_locks jobs; do
if mysql --defaults-extra-file="$preview_credentials" -N -e "SHOW TABLES LIKE '$table'" "$preview_db" | grep -q .; then
mysql --defaults-extra-file="$preview_credentials" -e "TRUNCATE TABLE \`$table\`" "$preview_db"
fi
done
step "Medien übernehmen"
mkdir -p "$preview/storage/app/public"
if [[ "$(realpath "$live/storage/app/public")" == "$(realpath "$preview/storage/app/public")" ]]; then
# Vorschau verlinkt auf die Live-Medien (preview-deployment.md, Schritt 3) – nichts zu kopieren
echo "Die Vorschau nutzt die Live-Medien per Link – übersprungen."
else
rsync -a --delete "$live/storage/app/public/" "$preview/storage/app/public/"
du -sh "$preview/storage/app/public"
fi
step "Migrationen und Seeder"
cd "$preview"
php artisan migrate --force
for seeder in "${seeders[@]}"; do
php artisan db:seed --class="$seeder" --force
done
step "Caches"
[[ -e public/storage ]] || php artisan storage:link
php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
step "Vorschau wieder online"
php artisan up
trap - ERR
echo
echo "Vorschau aufgefrischt: Daten und Medien von Live, Stand $(date '+%d.%m.%Y %H:%M')."

View file

@ -82,28 +82,28 @@ Auf dem Server als App-Benutzer (bei Ploi `ploi`):
2. Deploy-Key erzeugen und im Git-Server **nur lesend** am Repository eintragen:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/deploy_meineapp -N "" -C "meineapp-live"
cat ~/.ssh/deploy_meineapp.pub
ssh-keygen -t ed25519 -f ~/.ssh/deploy_b2inapp -N "" -C "b2inapp-live"
cat ~/.ssh/deploy_b2inapp.pub
```
3. `~/.ssh/config`:
```
Host git.example.com
Host forge.adametz.media
Port 222
User git
IdentityFile ~/.ssh/deploy_meineapp
IdentityFile ~/.ssh/deploy_b2inapp
IdentitiesOnly yes
```
4. Host-Key aufnehmen, Fingerprint mit dem im Git-Server angezeigten vergleichen:
```bash
ssh-keyscan -p 222 git.example.com >> ~/.ssh/known_hosts
ssh-keygen -lf <(ssh-keyscan -p 222 git.example.com 2>/dev/null)
ssh-keyscan -p 222 forge.adametz.media >> ~/.ssh/known_hosts
ssh-keygen -lf <(ssh-keyscan -p 222 forge.adametz.media 2>/dev/null)
```
5. Test: `git ls-remote ssh://git@git.example.com:222/team/meineapp.git` zeigt die Tags.
5. Test: `git ls-remote ssh://git@forge.adametz.media :222/kadmin/b2in.git` zeigt die Tags.
6. **FPM-Reload ohne Passwort testen** (Ploi erlaubt das dem Benutzer `ploi`):
```bash

97
package-lock.json generated
View file

@ -8,6 +8,7 @@
"@fontsource/instrument-sans": "^5.0.0",
"@fontsource/inter": "^5.0.0",
"@fontsource/poppins": "^5.0.0",
"@sentry/browser": "^11.6.0",
"@tailwindcss/vite": "^4.0.7",
"alpinejs": "^3.14.9",
"autoprefixer": "^10.4.20",
@ -723,6 +724,96 @@
"win32"
]
},
"node_modules/@sentry/browser": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-11.6.0.tgz",
"integrity": "sha512-zkqUIbRc9HCBMsoL67HOAr6PSfNzJSMw4JhytCHuDZ5O7KuOwwgBENGhVh3mr5lj2D47Bhv01MpwCJMP3yBnZQ==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "11.6.0",
"@sentry/conventions": "^0.26.0",
"@sentry/core": "11.6.0",
"@sentry/feedback": "11.6.0",
"@sentry/replay": "11.6.0",
"@sentry/replay-canvas": "11.6.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/browser-utils": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-11.6.0.tgz",
"integrity": "sha512-s4zuuE71NORfyNd6ciS27/o97qgfu70qDRdPCkLK8UOcs1REsaAQPgmvNBSADRYm4eeFwWejKBCQKjL5kOzLqw==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.26.0",
"@sentry/core": "11.6.0",
"web-vitals": "^6.2.3"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/conventions": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.26.0.tgz",
"integrity": "sha512-ttSRcoLf2kIENDIFzH12aJaVMdxnidMYLvVNwEX/JjXfXaQot8u5M47QmgAFOOhVJ4nQ2uU8wTCh+fwSPrrCfg==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.6.0.tgz",
"integrity": "sha512-PbITT1kfJYKJiS9fH4ZtOcwIONKyt+tUsTZSPtcAjPdkUDOzMKT61MkUa1Ze07VzNtLALT24ZdUOc6d9K4YoKA==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.26.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/feedback": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-11.6.0.tgz",
"integrity": "sha512-V52/+SkMcPdlldExPC9TWS2sZ+CC+8BSzvVbRtkQxvy+jTOXFAJfInaVLPAAVt8W4WbPw9iAxVMzwHGaZ2muBw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "11.6.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/replay": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-11.6.0.tgz",
"integrity": "sha512-cJyPYnF6QJA8ADRfAW06YoU7EVIebtWezBOCU9Vag5+W1VLBkXEvH7C4LbeMUiDmrqw92sumq06NgqipIxA7BA==",
"license": "MIT",
"dependencies": {
"@sentry/browser-utils": "11.6.0",
"@sentry/core": "11.6.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@sentry/replay-canvas": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-11.6.0.tgz",
"integrity": "sha512-cRuiC8N9xXDoaKEDpMPRDtmGAr0G2YMx1m2JrnrmTnMt9z/VcIKNwaiURTTW8W8u96/RyRQfflMHYARX7FG8YQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "11.6.0",
"@sentry/replay": "11.6.0"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz",
@ -2295,6 +2386,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/web-vitals": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz",
"integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View file

@ -13,6 +13,10 @@
"build:style2own": "THEME=style2own vite build --config vite.web.config.js"
},
"dependencies": {
"@fontsource/instrument-sans": "^5.0.0",
"@fontsource/inter": "^5.0.0",
"@fontsource/poppins": "^5.0.0",
"@sentry/browser": "^11.6.0",
"@tailwindcss/vite": "^4.0.7",
"alpinejs": "^3.14.9",
"autoprefixer": "^10.4.20",
@ -20,10 +24,7 @@
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.0",
"tailwindcss": "^4.0.7",
"vite": "^6.0",
"@fontsource/instrument-sans": "^5.0.0",
"@fontsource/inter": "^5.0.0",
"@fontsource/poppins": "^5.0.0"
"vite": "^6.0"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

View file

@ -33,5 +33,11 @@
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="BASIC_AUTH_ENABLED" value="false"/>
<!-- Tests provozieren Fehler absichtlich – nie an das echte GlitchTip, nie Push/Mail -->
<env name="SENTRY_LARAVEL_DSN" value="" force="true"/>
<env name="SENTRY_DSN" value="" force="true"/>
<env name="SENTRY_BROWSER_DSN" value="" force="true"/>
<env name="MONITORING_NTFY_URL" value="" force="true"/>
<env name="MONITORING_ALERT_EMAIL" value="" force="true"/>
</php>
</phpunit>

View file

@ -0,0 +1 @@
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();

View file

@ -0,0 +1 @@
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
{
"resources/css/web/shared-styles.css": {
"file": "shared-styles-BbGt7f_e.css",
"src": "resources/css/web/shared-styles.css",
"isEntry": true
},
"resources/css/web/theme-b2a.css": {
"file": "theme-b2a-BCAFYDZJ.css",
"src": "resources/css/web/theme-b2a.css",
"isEntry": true
},
"resources/css/web/theme-b2in.css": {
"file": "theme-b2in-DK_BFMo-.css",
"src": "resources/css/web/theme-b2in.css",
"isEntry": true
},
"resources/css/web/theme-stileigentum.css": {
"file": "theme-stileigentum-skwGRiP0.css",
"src": "resources/css/web/theme-stileigentum.css",
"isEntry": true
},
"resources/css/web/theme-style2own.css": {
"file": "theme-style2own-1F1Xts-B.css",
"src": "resources/css/web/theme-style2own.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "app-OG3MHWAj.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/js/error-reporting-sdk.js": {
"file": "error-reporting-sdk-CLmutOST.js",
"name": "error-reporting-sdk",
"src": "resources/js/error-reporting-sdk.js",
"isDynamicEntry": true
},
"resources/js/error-reporting.js": {
"file": "error-reporting-BiGrjXpD.js",
"name": "error-reporting",
"src": "resources/js/error-reporting.js",
"isEntry": true,
"dynamicImports": [
"resources/js/error-reporting-sdk.js"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();

View file

@ -0,0 +1 @@
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
{
"resources/css/web/shared-styles.css": {
"file": "shared-styles-BbGt7f_e.css",
"src": "resources/css/web/shared-styles.css",
"isEntry": true
},
"resources/css/web/theme-b2a.css": {
"file": "theme-b2a-BCAFYDZJ.css",
"src": "resources/css/web/theme-b2a.css",
"isEntry": true
},
"resources/css/web/theme-b2in.css": {
"file": "theme-b2in-DK_BFMo-.css",
"src": "resources/css/web/theme-b2in.css",
"isEntry": true
},
"resources/css/web/theme-stileigentum.css": {
"file": "theme-stileigentum-skwGRiP0.css",
"src": "resources/css/web/theme-stileigentum.css",
"isEntry": true
},
"resources/css/web/theme-style2own.css": {
"file": "theme-style2own-1F1Xts-B.css",
"src": "resources/css/web/theme-style2own.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "app-OG3MHWAj.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/js/error-reporting-sdk.js": {
"file": "error-reporting-sdk-CLmutOST.js",
"name": "error-reporting-sdk",
"src": "resources/js/error-reporting-sdk.js",
"isDynamicEntry": true
},
"resources/js/error-reporting.js": {
"file": "error-reporting-BiGrjXpD.js",
"name": "error-reporting",
"src": "resources/js/error-reporting.js",
"isEntry": true,
"dynamicImports": [
"resources/js/error-reporting-sdk.js"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();

View file

@ -0,0 +1 @@
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
{
"resources/css/portal.css": {
"file": "portal-CtGNvLu1.css",
"src": "resources/css/portal.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "app-OG3MHWAj.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/js/error-reporting-sdk.js": {
"file": "error-reporting-sdk-CLmutOST.js",
"name": "error-reporting-sdk",
"src": "resources/js/error-reporting-sdk.js",
"isDynamicEntry": true
},
"resources/js/error-reporting.js": {
"file": "error-reporting-BiGrjXpD.js",
"name": "error-reporting",
"src": "resources/js/error-reporting.js",
"isEntry": true,
"dynamicImports": [
"resources/js/error-reporting-sdk.js"
]
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();

View file

@ -0,0 +1 @@
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
{
"resources/css/web/shared-styles.css": {
"file": "shared-styles-BbGt7f_e.css",
"src": "resources/css/web/shared-styles.css",
"isEntry": true
},
"resources/css/web/theme-b2a.css": {
"file": "theme-b2a-BCAFYDZJ.css",
"src": "resources/css/web/theme-b2a.css",
"isEntry": true
},
"resources/css/web/theme-b2in.css": {
"file": "theme-b2in-DK_BFMo-.css",
"src": "resources/css/web/theme-b2in.css",
"isEntry": true
},
"resources/css/web/theme-stileigentum.css": {
"file": "theme-stileigentum-skwGRiP0.css",
"src": "resources/css/web/theme-stileigentum.css",
"isEntry": true
},
"resources/css/web/theme-style2own.css": {
"file": "theme-style2own-1F1Xts-B.css",
"src": "resources/css/web/theme-style2own.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "app-OG3MHWAj.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/js/error-reporting-sdk.js": {
"file": "error-reporting-sdk-CLmutOST.js",
"name": "error-reporting-sdk",
"src": "resources/js/error-reporting-sdk.js",
"isDynamicEntry": true
},
"resources/js/error-reporting.js": {
"file": "error-reporting-BiGrjXpD.js",
"name": "error-reporting",
"src": "resources/js/error-reporting.js",
"isEntry": true,
"dynamicImports": [
"resources/js/error-reporting-sdk.js"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(function(){let o=null;document.readyState==="loading"?document.addEventListener("DOMContentLoaded",r):r();function r(){s(),u(),c(),document.addEventListener("livewire:navigated",function(){s()}),document.addEventListener("livewire:load",function(){s()}),window.addEventListener("livewire:update",function(){setTimeout(()=>{s()},100)})}function s(){const n={threshold:.15,rootMargin:"0px 0px -80px 0px"};o||(o=new IntersectionObserver(e=>{e.forEach(t=>{t.isIntersecting&&(setTimeout(()=>{t.target.classList.add("is-visible")},50),o.unobserve(t.target))})},n)),document.querySelectorAll(".scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)").forEach(e=>{e.classList.add("observed"),o.observe(e)}),window.appointmentLinksInitialized||(document.addEventListener("click",function(e){const t=e.target.closest("a[data-open-appointment]");t&&(e.preventDefault(),window.dispatchEvent(new CustomEvent("open-appointment",{detail:{topic:t.dataset.appointmentTopic||null}})))}),window.appointmentLinksInitialized=!0),window.smoothScrollInitialized||(document.addEventListener("click",function(e){const t=e.target.closest('a[href^="#"]');if(t&&t.hash){const l=document.querySelector(t.hash);if(l){e.preventDefault();const f=l.getBoundingClientRect().top+window.pageYOffset-80;window.scrollTo({top:f,behavior:"smooth"})}}}),window.smoothScrollInitialized=!0)}function c(){const n=document.getElementById("main-header");if(!n)return;function i(){(window.pageYOffset||document.documentElement.scrollTop)>50?n.classList.add("scrolled"):n.classList.remove("scrolled")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}function u(){const n=document.createElement("div");n.className="scroll-progress-bar",document.body.appendChild(n);function i(){const t=window.innerHeight,l=document.documentElement.scrollHeight,d=window.pageYOffset||document.documentElement.scrollTop,a=d/(l-t)*100;n.style.width=`${Math.min(a,100)}%`,d>100?n.classList.add("visible"):n.classList.remove("visible")}let e=!1;window.addEventListener("scroll",function(){e||(window.requestAnimationFrame(function(){i(),e=!1}),e=!0)}),i()}})();

View file

@ -0,0 +1 @@
const y="modulepreload",A=function(e){return"https://b2in.test/build/"+e},h={},_=function(r,t,a){let u=Promise.resolve();if(t&&t.length>0){let o=function(s){return Promise.all(s.map(c=>Promise.resolve(c).then(f=>({status:"fulfilled",value:f}),f=>({status:"rejected",reason:f}))))};document.getElementsByTagName("link");const n=document.querySelector("meta[property=csp-nonce]"),m=(n==null?void 0:n.nonce)||(n==null?void 0:n.getAttribute("nonce"));u=o(t.map(s=>{if(s=A(s),s in h)return;h[s]=!0;const c=s.endsWith(".css"),f=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${s}"]${f}`))return;const l=document.createElement("link");if(l.rel=c?"stylesheet":y,c||(l.as="script"),l.crossOrigin="",l.href=s,m&&l.setAttribute("nonce",m),document.head.appendChild(l),c)return new Promise((b,S)=>{l.addEventListener("load",b),l.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${s}`)))})}))}function d(o){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=o,window.dispatchEvent(n),!n.defaultPrevented)throw o}return u.then(o=>{for(const n of o||[])n.status==="rejected"&&d(n.reason);return r().catch(d)})},i=(()=>{const e=document.querySelector('meta[name="pk-error-reporting"]');try{return e?JSON.parse(e.content):null}catch{return null}})(),v=["page","q","tab","sort"],P=new RegExp("(?<=\\/)[A-Za-z0-9_-]{32,}(?=\\/|$)","g"),w=/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;function p(e){try{const r=new URL(e,window.location.origin);r.pathname=r.pathname.replace(P,"[Filtered]");for(const t of[...r.searchParams.keys()])!v.includes(t)&&!t.startsWith("utm_")&&r.searchParams.set(t,"[Filtered]");return r.hash="",r.toString().replace(w,"[email]")}catch{return"[Filtered]"}}let E=null;function R(){return E??(E=_(()=>import("./error-reporting-sdk-CLmutOST.js"),[]).then(e=>(e.init({dsn:i.dsn,release:i.release||void 0,environment:i.environment,sampleRate:i.sampleRate??1,sendDefaultPii:!1,integrations:r=>r.filter(t=>!["GlobalHandlers","Breadcrumbs","BrowserSession","BrowserApiErrors"].includes(t.name)),ignoreErrors:["ResizeObserver loop","Script error.","AbortError"],denyUrls:[/^chrome-extension:\/\//,/^moz-extension:\/\//,/^safari-(web-)?extension:\/\//],beforeSend(r){var t,a,u,d;(t=r.request)!=null&&t.url&&(r.request.url=p(r.request.url)),(u=(a=r.request)==null?void 0:a.headers)!=null&&u.Referer&&(r.request.headers.Referer=p(r.request.headers.Referer));for(const o of((d=r.exception)==null?void 0:d.values)??[])o.value&&(o.value=o.value.replace(w,"[email]"));return r}}),i.userId&&e.setUser({id:String(i.userId)}),e.setTag("area",i.area),e))),E}function k(e){var a;if(e instanceof Error)return e;const r=((a=e==null?void 0:e.constructor)==null?void 0:a.name)??typeof e;let t;try{t=typeof e=="string"?e:JSON.stringify(e)}catch{t=String(e)}return new Error(`Unbehandelte Ablehnung (${r}): ${String(t??"").slice(0,300)}`)}function g(e){R().then(r=>r.captureException(e)).catch(()=>{})}i!=null&&i.dsn&&(window.addEventListener("error",e=>{e.error&&g(e.error)}),window.addEventListener("unhandledrejection",e=>{g(k(e.reason))}));

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
{
"resources/css/web/shared-styles.css": {
"file": "shared-styles-BbGt7f_e.css",
"src": "resources/css/web/shared-styles.css",
"isEntry": true
},
"resources/css/web/theme-b2a.css": {
"file": "theme-b2a-BCAFYDZJ.css",
"src": "resources/css/web/theme-b2a.css",
"isEntry": true
},
"resources/css/web/theme-b2in.css": {
"file": "theme-b2in-DK_BFMo-.css",
"src": "resources/css/web/theme-b2in.css",
"isEntry": true
},
"resources/css/web/theme-stileigentum.css": {
"file": "theme-stileigentum-skwGRiP0.css",
"src": "resources/css/web/theme-stileigentum.css",
"isEntry": true
},
"resources/css/web/theme-style2own.css": {
"file": "theme-style2own-1F1Xts-B.css",
"src": "resources/css/web/theme-style2own.css",
"isEntry": true
},
"resources/js/app.js": {
"file": "app-OG3MHWAj.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true
},
"resources/js/error-reporting-sdk.js": {
"file": "error-reporting-sdk-CLmutOST.js",
"name": "error-reporting-sdk",
"src": "resources/js/error-reporting-sdk.js",
"isDynamicEntry": true
},
"resources/js/error-reporting.js": {
"file": "error-reporting-BiGrjXpD.js",
"name": "error-reporting",
"src": "resources/js/error-reporting.js",
"isEntry": true,
"dynamicImports": [
"resources/js/error-reporting-sdk.js"
]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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';

View file

@ -0,0 +1,128 @@
/**
* Browserfehler an GlitchTip.
*
* 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 <meta name="pk-error-reporting">, 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 = ['page', 'q', 'tab', 'sort']; // wie ErrorReportScrubber::AllowedQueryParameters
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;
}
/**
* Promise-Ablehnungen mit Objekt statt Error kamen als „Error: [object Object]“
* an — Typ und Inhalt mitschicken,
* damit die Quelle erkennbar ist.
*/
function rejectionToError(reason) {
if (reason instanceof Error) {
return reason;
}
const kind = reason?.constructor?.name ?? typeof reason;
let text;
try {
text = typeof reason === 'string' ? reason : JSON.stringify(reason);
} catch {
text = String(reason);
}
return new Error(`Unbehandelte Ablehnung (${kind}): ${String(text ?? '').slice(0, 300)}`);
}
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(rejectionToError(event.reason));
});
}

View file

@ -0,0 +1,14 @@
@props(['build' => 'build', 'area' => 'web'])
{{-- Browserfehler an GlitchTip. Ohne SENTRY_BROWSER_DSN
wird nichts eingebunden; das SDK selbst lädt erst beim ersten Fehler. --}}
@if (filled(config('monitoring.browser.dsn')))
<meta name="pk-error-reporting" content="{{ json_encode([
'dsn' => config('monitoring.browser.dsn'),
'release' => config('sentry.release'),
'environment' => config('sentry.environment') ?? app()->environment(),
'sampleRate' => config('monitoring.browser.sample_rate'),
'userId' => auth()->id(),
'area' => $area,
]) }}" />
@vite(['resources/js/error-reporting.js'], $build)
@endif

View file

@ -0,0 +1,47 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')->add(
match ($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
},
);
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="M12 7v14" />
<path d="M16 12h2" />
<path d="M16 8h2" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
<path d="M6 12h2" />
<path d="M6 8h2" />
</svg>

View file

@ -0,0 +1,43 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')->add(
match ($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
},
);
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>

View file

@ -0,0 +1,45 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')->add(
match ($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
},
);
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" />
<circle cx="13" cy="12" r="2" />
<path d="M18 19c-2.8 0-5-2.2-5-5v8" />
<circle cx="20" cy="19" r="2" />
</svg>

View file

@ -0,0 +1,45 @@
{{-- Credit: Lucide (https://lucide.dev) --}}
@props([
'variant' => 'outline',
])
@php
if ($variant === 'solid') {
throw new \Exception('The "solid" variant is not supported in Lucide.');
}
$classes = Flux::classes('shrink-0')->add(
match ($variant) {
'outline' => '[:where(&)]:size-6',
'solid' => '[:where(&)]:size-6',
'mini' => '[:where(&)]:size-5',
'micro' => '[:where(&)]:size-4',
},
);
$strokeWidth = match ($variant) {
'outline' => 2,
'mini' => 2.25,
'micro' => 2.5,
};
@endphp
<svg
{{ $attributes->class($classes) }}
data-flux-icon
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="{{ $strokeWidth }}"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-slot="icon"
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>

View file

@ -24,6 +24,7 @@
<meta name="theme-color" content="#ffffff">
@vite(['resources/css/portal.css', 'resources/js/app.js'], 'build/portal')
<x-monitoring.browser-errors build="build/portal" area="portal" />
@livewireStyles
@fluxAppearance

View file

@ -35,6 +35,7 @@
\App\Helpers\ThemeHelper::getThemeCssPath(),
'resources/js/app.js'
], 'build/' . $theme)
<x-monitoring.browser-errors :build="'build/' . $theme" area="web" />
<!-- Sticky Header Script -->
<script>

View file

@ -59,6 +59,7 @@
\App\Helpers\ThemeHelper::getThemeCssPath(),
'resources/js/app.js'
], 'build/' . $theme)
<x-monitoring.browser-errors :build="'build/' . $theme" area="web" />
<!-- Sticky Header Script -->
<script>

View file

@ -1,5 +1,8 @@
<?php
use App\Console\Commands\MonitoringHeartbeat;
use App\Support\OperationalAlert;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@ -8,4 +11,25 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('cabinet:reset-overrides')->dailyAt('00:00');
// Betriebs-Alert, wenn ein geplanter Task fehlschlägt – an JEDEN Task hängen
$alertOnFailure = fn (string $task) => function (Event $event) use ($task): void {
OperationalAlert::send("Geplanter Task fehlgeschlagen: {$task}", ['exit_code' => $event->exitCode]);
};
Schedule::command('cabinet:reset-overrides')
->dailyAt('00:00')
->withoutOverlapping(10)
->onFailure($alertOnFailure('cabinet:reset-overrides'));
// Herzschlag: fällt der Scheduler 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
Schedule::command('queue:prune-failed', ['--hours=720'])
->dailyAt('03:55')
->withoutOverlapping(30)
->onFailure($alertOnFailure('queue:prune-failed'));

View file

@ -63,6 +63,17 @@ it('leaves the other module settings alone', function () {
expect($settings['show_footer'])->toBeFalse();
});
it('keeps modules that already carry the interior logo, including a claim maintained since', function () {
$module = DisplayVersion::factory()->create([
'type' => DisplayVersionType::B2in,
'settings' => ['theme' => 'dark', 'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg', 'header_claim' => 'Eigener Claim'],
]);
$this->seed(\Database\Seeders\DisplayInteriorBrandingSeeder::class);
expect($module->fresh()->settings['header_claim'])->toBe('Eigener Claim');
});
it('does not touch modules of other types', function () {
$video = DisplayVersion::factory()->create([
'type' => DisplayVersionType::VideoDisplay,

View file

@ -0,0 +1,88 @@
<?php
use App\Enums\DisplayVersionType;
use App\Models\Display;
use App\Models\DisplayMedia;
use App\Models\DisplayPlaylist;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use Database\Seeders\InteriorReleaseSeeder;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* Release-Seeder für Vorschau und Live (08.10.2026): bringt den alten Website-Stand auf den
* neuen, lässt Displays und Mediathek stehen und ändert beim zweiten Lauf nichts mehr.
*/
beforeEach(function () {
Storage::fake('public');
});
/**
* Inhalt der Tabellen, die der Release-Seeder beschreibt – ohne Zeitstempel.
*
* @return array<string, mixed>
*/
function releaseSnapshot(): array
{
$tables = ['flux_cms_contents', 'cms_brands', 'cms_articles', 'flux_cms_media', 'display_versions', 'display_version_items', 'display_media', 'displays'];
return collect($tables)->mapWithKeys(fn (string $table): array => [
$table => DB::table($table)->orderBy('id')->get()
->map(fn (object $row): array => collect((array) $row)->except(['created_at', 'updated_at'])->all())
->all(),
])->all();
}
it('brings the old website content up to date and keeps the displays and the media library', function () {
// Alter Website-Stand, wie er auf Live liegt
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => [
'de' => ['title' => 'Connecting Design and Property', 'subtitle' => 'Alt'],
]]);
// Seit der Vorschau auf Live gepflegt: Display, Modul, Playlist, Display-Mediathek, CMS-Mediathek
$module = DisplayVersion::factory()->create([
'type' => DisplayVersionType::B2in,
'settings' => ['theme' => 'dark', 'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg', 'header_claim' => 'Herbstaktion'],
]);
$item = DisplayVersionItem::factory()->create([
'display_version_id' => $module->id,
'content' => ['image_url' => '/storage/display-media/herbst.webp', 'duration' => 8],
]);
$display = Display::factory()->create(['name' => 'Kundenstopper']);
$playlist = DisplayPlaylist::factory()->create(['display_id' => $display->id]);
$displayMedia = DisplayMedia::factory()->create(['filename' => 'herbst.webp']);
$upload = CmsMedia::create([
'filename' => 'kunde-upload.jpg', 'disk' => 'public', 'path' => 'cms/media/originals/kunde.jpg',
'type' => 'image', 'mime_type' => 'image/jpeg', 'file_size' => 1, 'collection' => 'Website', 'conversions' => [],
]);
$this->seed(InteriorReleaseSeeder::class);
$hero = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first()->getTranslation('value', 'de');
expect($hero['title'])->not->toContain('Connecting Design')
->and($hero['slides'])->toHaveCount(11);
expect($module->fresh()->settings['header_claim'])->toBe('Herbstaktion')
->and($item->fresh()->content)->toBe(['image_url' => '/storage/display-media/herbst.webp', 'duration' => 8])
->and($display->fresh()->name)->toBe('Kundenstopper')
->and($playlist->fresh())->not->toBeNull()
->and($displayMedia->fresh()->filename)->toBe('herbst.webp')
->and($upload->fresh())->not->toBeNull()
->and(CmsMedia::query()->where('filename', 'b2in/slideshow/rotpunkt-fenix-house.webp')->exists())->toBeTrue();
$this->get('/')->assertSuccessful()->assertSee('B2in Interior');
$this->get('/marken')->assertSuccessful();
$this->get('/about')->assertSuccessful();
});
it('changes nothing on a second run', function () {
$this->seed(InteriorReleaseSeeder::class);
$first = releaseSnapshot();
$this->seed(InteriorReleaseSeeder::class);
expect(releaseSnapshot())->toEqual($first);
});

View file

@ -0,0 +1,76 @@
<?php
use App\Services\Monitoring\HealthProbe;
use Illuminate\Console\Scheduling\Schedule;
/**
* Einbau des Monitorings in B2in (08.10.2026): /health auf jeder Domain, auch hinter der
* Basic Auth, Browserfehler in Web und Portal, jeder geplante Task mit Alarm.
*/
beforeEach(function () {
config()->set('monitoring.health.token', 'b2in-monitor-token');
config()->set('monitoring.health.disk_min_free_percent', 0);
app(HealthProbe::class)->recordHeartbeat();
});
afterEach(function () {
@unlink(HealthProbe::heartbeatPath());
});
it('answers the deep health check on every domain', function (string $host) {
$this->get("https://{$host}/health", ['Authorization' => 'Bearer b2in-monitor-token'])
->assertOk()
->assertJsonPath('status', 'ok');
$this->get("https://{$host}/health")->assertNotFound();
})->with(['b2in.test', 'portal.b2in.test', 'stileigentum.test']);
it('lets the monitor past the basic auth', function () {
config()->set('auth.basic', ['user' => 'kunde', 'password' => 'geheim']);
$request = Illuminate\Http\Request::create('https://irgendwo.example/health');
$passed = (new App\Http\Middleware\BasicAuthMiddleware)->handle($request, fn () => response('weiter'));
expect($passed->getContent())->toBe('weiter');
$other = Illuminate\Http\Request::create('https://irgendwo.example/admin');
expect((new App\Http\Middleware\BasicAuthMiddleware)->handle($other, fn () => response('weiter'))->getStatusCode())->toBe(401);
});
it('embeds browser error reporting in the website only with a dsn', function () {
$this->get('/')->assertSuccessful()->assertDontSee('pk-error-reporting', false);
config()->set('monitoring.browser.dsn', 'https://public@errors.example.com/2');
$this->get('/')->assertSuccessful()
->assertSee('name="pk-error-reporting"', false)
->assertSee('&quot;area&quot;:&quot;web&quot;', false);
});
it('builds the error reporting script for the website and the portal', function () {
expect(file_get_contents(base_path('vite.web.config.js')))->toContain('"resources/js/error-reporting.js"')
->and(file_get_contents(base_path('vite.portal.config.js')))->toContain('"resources/js/error-reporting.js"')
->and(file_get_contents(resource_path('views/partials/head.blade.php')))
->toContain('<x-monitoring.browser-errors build="build/portal" area="portal" />');
});
it('alerts on every scheduled task and keeps the heartbeat running', function () {
$events = collect(app(Schedule::class)->events());
expect($events->map->command->implode("\n"))
->toContain('monitoring:heartbeat')
->toContain('queue:prune-failed')
->toContain('cabinet:reset-overrides');
foreach ($events as $event) {
$callbacks = (fn () => $this->afterCallbacks)->call($event);
expect($callbacks)->not->toBeEmpty("{$event->command} hat keinen Alarm bei Fehlschlag.");
}
});
it('shows the delivered version in artisan about', function () {
config()->set('app.version', 'v2.0.0');
$this->artisan('about', ['--only' => 'b2in'])->expectsOutputToContain('v2.0.0')->assertSuccessful();
});

View file

@ -0,0 +1,38 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Blade;
/**
* Browserfehler: Meta-Konfiguration und Skript nur mit
* DSN; die User-ID ist der einzige Personenbezug.
*/
beforeEach(function () {
$this->withoutVite();
});
test('nothing is rendered without a browser dsn', function () {
config()->set('monitoring.browser.dsn', null);
expect(Blade::render('<x-monitoring.browser-errors />'))->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('<x-monitoring.browser-errors build="build/portal" area="portal" />');
preg_match('/<meta name="pk-error-reporting" content="([^"]+)"/', $html, $match);
$config = json_decode(html_entity_decode($match[1]), true);
expect($config)->toMatchArray([
'dsn' => 'https://public@glitchtip.test/2',
'release' => 'abc1234',
'userId' => $user->id,
'area' => 'portal',
])
->and($html)->not->toContain('kunde@example.com');
});

View file

@ -0,0 +1,95 @@
<?php
use App\Models\User;
use App\Support\Monitoring\ErrorReportScrubber;
use Illuminate\Support\Facades\Route;
use Sentry\Event;
use Sentry\ExceptionDataBag;
use Sentry\SentrySdk;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Tests\Support\FakeGlitchTip;
/**
* GlitchTip-Anbindung: gemeldete Exceptions gehen
* raus, aber nur mit User-ID, ohne Tokens und ohne E-Mail-Adressen.
*/
test('reported exceptions reach glitchtip with the user id only', function () {
$glitchTip = FakeGlitchTip::install();
$user = User::factory()->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('a mistyped artisan command is not reported', function () {
$glitchTip = FakeGlitchTip::install();
report(new CommandNotFoundException('Command "config:cachels" is not defined.'));
expect($glitchTip->events)->toBe([]);
});
test('request data loses tokens, signatures and bodies', function () {
$scrubbed = ErrorReportScrubber::scrubRequest([
'url' => 'https://app.test/einladung/5?signature=abc123&expires=1700000000&page=2',
'query_string' => 'signature=abc123&expires=1700000000&page=2&utm_source=nl',
'data' => ['password' => 'geheim'],
'cookies' => ['app_session' => 'x'],
'headers' => ['referer' => ['https://app.test/reset-password/'.str_repeat('a', 64).'?email=kunde@example.com']],
]);
expect($scrubbed['url'])->toBe('https://app.test/einladung/5?signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2')
->and($scrubbed['query_string'])->toBe('signature=%5BFiltered%5D&expires=%5BFiltered%5D&page=2&utm_source=nl')
->and($scrubbed)->not->toHaveKeys(['data', 'cookies'])
->and($scrubbed['headers']['referer'][0])->toBe('https://app.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();
});
test('the test suite never reports to the real glitchtip', function () {
// Ohne diese Absicherung landeten absichtlich provozierte Fehler
// („Queue weg“, „Datenbank nicht erreichbar“) als Issues in GlitchTip.
expect(config('sentry.dsn'))->toBeEmpty()
->and(config('monitoring.browser.dsn'))->toBeEmpty()
->and(SentrySdk::getCurrentHub()->getClient()?->getOptions()->getDsn())->toBeNull();
});

View file

@ -0,0 +1,71 @@
<?php
use App\Models\User;
use App\Support\Monitoring\ErrorSignal;
use Illuminate\Support\Facades\Route;
use Tests\Support\FakeGlitchTip;
/**
* Fehler ohne Exception: tote interne Links,
* 403/429 für Angemeldete und 419 werden als GlitchTip-Warnung gemeldet.
*/
beforeEach(function () {
$this->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);
});

View file

@ -0,0 +1,91 @@
<?php
use App\Services\Monitoring\HealthProbe;
use Illuminate\Support\Facades\DB;
/**
* Tiefer Health-Check und Scheduler-Herzschlag. Der Herzschlag liegt in
* einer Datei unter storage/ — vor und nach jedem Test entfernen.
*/
beforeEach(function () {
@unlink(HealthProbe::heartbeatPath());
config()->set('monitoring.health.token', 'geheimes-monitor-token');
config()->set('monitoring.health.disk_min_free_percent', 0);
config()->set('queue.default', 'database');
});
afterEach(function () {
@unlink(HealthProbe::heartbeatPath());
});
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 skips the session and the web middleware group', function () {
app(HealthProbe::class)->recordHeartbeat();
$this->get('/health', healthHeaders())
->assertOk()
->assertCookieMissing(config('session.cookie'));
});

View file

@ -0,0 +1,82 @@
<?php
use App\Support\OperationalAlert;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Client\Request;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
/**
* Betriebs-Alerts: Log immer, Mail und ntfy unabhängig voneinander und
* Best-Effort — ein ausgefallener Kanal darf den auslösenden Prozess nie stören.
*/
beforeEach(function () {
config()->set('monitoring.alerts.enabled', true);
config()->set('monitoring.alerts.mail_to', null);
config()->set('monitoring.alerts.ntfy', ['url' => null, 'topic' => null, 'token' => null, 'priority' => 4]);
});
test('an alert always logs critical to the monitoring channel', function () {
$monitoringLog = Mockery::spy(LoggerInterface::class);
Log::shouldReceive('channel')->with('monitoring')->andReturn($monitoringLog);
OperationalAlert::send('Geplanter Task fehlgeschlagen: demo', ['exit_code' => 1]);
$monitoringLog->shouldHaveReceived('critical')->once();
});
test('an alert mails the configured recipient', function () {
config()->set('monitoring.alerts.mail_to', 'ops@example.com');
Event::fake([MessageSent::class]);
OperationalAlert::send('Etwas ist kaputt', ['exit_code' => 1]);
Event::assertDispatched(MessageSent::class, function (MessageSent $event): bool {
$recipients = array_map(fn ($address): string => $address->getAddress(), $event->message->getTo());
return in_array('ops@example.com', $recipients, true)
&& str_contains((string) $event->message->getSubject(), 'Etwas ist kaputt');
});
});
test('an alert is pushed to ntfy as json with token and priority', function () {
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com/', 'topic' => 'app-ops', 'token' => 'tk_geheim', 'priority' => 4]);
Http::fake(['push.example.com*' => Http::response(['id' => 'x'])]);
OperationalAlert::send('Queue-Job fehlgeschlagen: Demo', ['exit_code' => 1]);
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://push.example.com'
&& $request->hasHeader('Authorization', 'Bearer tk_geheim')
&& $request['topic'] === 'app-ops'
&& $request['priority'] === 4
&& str_contains($request['message'], 'exit_code: 1'));
});
test('a failing push never bubbles up and nothing is sent when alerts are off', function () {
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]);
Http::fake(['push.example.com*' => Http::response('down', 502)]);
OperationalAlert::send('Etwas ist kaputt');
Http::assertSentCount(1);
config()->set('monitoring.alerts.enabled', false);
OperationalAlert::send('Etwas ist kaputt');
Http::assertSentCount(1);
});
test('a failed queue job triggers an alert via the registered listener', function () {
config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'app-ops', 'token' => null, 'priority' => 4]);
Http::fake();
$job = Mockery::mock(Job::class);
$job->shouldReceive('resolveName')->andReturn('App\\Jobs\\Demo');
$job->shouldReceive('getQueue')->andReturn('default');
event(new JobFailed('database', $job, new RuntimeException('boom')));
Http::assertSent(fn (Request $request): bool => str_contains($request['message'], 'App\\Jobs\\Demo'));
});

View file

@ -0,0 +1,39 @@
<?php
use App\Jobs\Monitoring\QueueProbe;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Sleep;
/**
* Worker-Prüfung am Ende von deploy.sh: ohne laufenden Worker (Supervisor
* gestoppt) soll der Deploy das sofort melden.
*/
test('a running worker picks up the probe job', function () {
Sleep::fake();
$this->artisan('monitoring:queue-probe', ['--wait' => 30])
->expectsOutputToContain('Queue-Worker läuft (Test-Job nach 0 s abgeholt).')
->assertSuccessful();
Sleep::assertSleptTimes(0);
});
test('without a worker the probe fails after the waiting time with a hint', function () {
Sleep::fake();
Queue::fake();
$this->artisan('monitoring:queue-probe', ['--wait' => 5])
->expectsOutputToContain('Kein Queue-Worker hat den Test-Job innerhalb von 5 s abgeholt.')
->expectsOutputToContain('Worker prüfen')
->assertFailed();
Queue::assertPushed(QueueProbe::class, fn (QueueProbe $job): bool => $job->queue === null);
Sleep::assertSleptTimes(5);
});
test('the probe job only acknowledges its own token', function () {
(new QueueProbe('abc'))->handle();
expect(cache()->has(QueueProbe::cacheKey('abc')))->toBeTrue()
->and(cache()->has(QueueProbe::cacheKey('xyz')))->toBeFalse();
});

View file

@ -0,0 +1,63 @@
<?php
namespace Tests\Support;
use Sentry\ClientBuilder;
use Sentry\Event;
use Sentry\SentrySdk;
use Sentry\Transport\Result;
use Sentry\Transport\ResultStatus;
use Sentry\Transport\TransportInterface;
/**
* Fängt Sentry-Events ab, statt sie an GlitchTip zu senden. Der Client wird
* mit den Datenschutz-Optionen aus config/sentry.php gebaut, damit Tests den
* echten `before_send`-Weg sehen.
*
* Nutzung: $glitchTip = FakeGlitchTip::install(); … $glitchTip->events
*/
class FakeGlitchTip implements TransportInterface
{
/** @var list<Event> */
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<string>
*/
public function messages(): array
{
return array_values(array_map(
static fn (Event $event): string => (string) ($event->getMessage() ?? $event->getExceptions()[0]?->getValue()),
$this->events,
));
}
}

View file

@ -6,5 +6,11 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
// Eigene Herzschlag-Datei je Testprozess – parallele Läufe kommen sich sonst in die Quere.
config()->set('monitoring.scheduler.heartbeat_path', sys_get_temp_dir().'/b2in-test-heartbeat-'.getmypid());
}
}

View file

@ -0,0 +1,38 @@
<?php
use App\Support\Monitoring\Release;
/**
* Release-Kennung für GlitchTip: SENTRY_RELEASE vor der
* beim SFTP-Deploy hochgeladenen Datei RELEASE.
*/
beforeEach(function () {
$this->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', '<git rev-parse --short HEAD> # bei jedem Deploy');
expect(Release::current(null, $this->dir))->toBeNull();
});

View file

@ -15,7 +15,7 @@ const httpsConfig =
export default defineConfig({
plugins: [
laravel({
input: ["resources/css/portal.css", "resources/js/app.js"],
input: ["resources/css/portal.css", "resources/js/app.js", "resources/js/error-reporting.js"],
refresh: ["resources/views/portal/**/*.blade.php"],
}),
tailwindcss({

View file

@ -23,6 +23,7 @@ export default defineConfig({
"resources/css/web/theme-stileigentum.css",
"resources/css/web/theme-style2own.css",
"resources/js/app.js",
"resources/js/error-reporting.js",
],
refresh: ["resources/views/web/**/*.blade.php"],
}),