diff --git a/.env.example b/.env.example index d621a98..c4bc767 100644 --- a/.env.example +++ b/.env.example @@ -87,5 +87,21 @@ OAUTH_JWT_KID=fotospiel-jwt OAUTH_KEY_STORE= OAUTH_REFRESH_ENFORCE_IP=true OAUTH_REFRESH_ALLOW_SUBNET=false +OAUTH_REFRESH_MAX_ACTIVE=5 +OAUTH_REFRESH_AUDIT_RETENTION_DAYS=90 +JOIN_TOKEN_FAILURE_LIMIT=10 +JOIN_TOKEN_FAILURE_DECAY=5 +JOIN_TOKEN_ACCESS_LIMIT=120 +JOIN_TOKEN_ACCESS_DECAY=1 +JOIN_TOKEN_DOWNLOAD_LIMIT=60 +JOIN_TOKEN_DOWNLOAD_DECAY=1 + +# Security scanning +SECURITY_AV_ENABLED=false +SECURITY_AV_BINARY=/usr/bin/clamscan +SECURITY_AV_ARGUMENTS=--no-summary +SECURITY_AV_TIMEOUT=60 +SECURITY_STRIP_EXIF=true +SECURITY_SCAN_QUEUE=media-security diff --git a/app/Console/Commands/OAuthListKeysCommand.php b/app/Console/Commands/OAuthListKeysCommand.php new file mode 100644 index 0000000..f88da40 --- /dev/null +++ b/app/Console/Commands/OAuthListKeysCommand.php @@ -0,0 +1,69 @@ +error("Key store path does not exist: {$storage}"); + return self::FAILURE; + } + + $directories = collect(File::directories($storage)) + ->filter(fn ($path) => Str::lower(basename($path)) !== 'archive') + ->values() + ->map(function (string $path) use ($currentKid) { + $kid = basename($path); + $publicKey = $path.DIRECTORY_SEPARATOR.'public.key'; + $privateKey = $path.DIRECTORY_SEPARATOR.'private.key'; + + return [ + 'kid' => $kid, + 'status' => $kid === $currentKid ? 'current' : 'legacy', + 'public' => File::exists($publicKey), + 'private' => File::exists($privateKey), + 'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null, + 'path' => $path, + ]; + }) + ->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid']) + ->values(); + + if ($this->option('json')) { + $this->line($directories->toJson(JSON_PRETTY_PRINT)); + return self::SUCCESS; + } + + if ($directories->isEmpty()) { + $this->warn('No signing key directories found.'); + return self::SUCCESS; + } + + $this->table( + ['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'], + $directories->map(fn ($entry) => [ + $entry['kid'], + $entry['status'], + $entry['public'] ? 'yes' : 'no', + $entry['private'] ? 'yes' : 'no', + $entry['updated_at'] ?? 'n/a', + $entry['path'], + ]) + ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/OAuthPruneKeysCommand.php b/app/Console/Commands/OAuthPruneKeysCommand.php new file mode 100644 index 0000000..db472b8 --- /dev/null +++ b/app/Console/Commands/OAuthPruneKeysCommand.php @@ -0,0 +1,77 @@ +error("Key store path does not exist: {$storage}"); + return self::FAILURE; + } + + $days = (int) $this->option('days'); + $cutoff = now()->subDays($days); + + $candidates = collect(File::directories($storage)) + ->reject(fn ($path) => Str::lower(basename($path)) === 'archive') + ->filter(function (string $path) use ($currentKid, $cutoff) { + $kid = basename($path); + if ($kid === $currentKid) { + return false; + } + + $lastModified = File::lastModified($path); + + return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified)); + }) + ->values(); + + if ($candidates->isEmpty()) { + $this->info("No legacy key directories older than {$days} days were found."); + return self::SUCCESS; + } + + $this->table( + ['KID', 'Last Modified', 'Path'], + $candidates->map(fn ($path) => [ + basename($path), + date('c', File::lastModified($path)), + $path, + ]) + ); + + if ($this->option('dry-run')) { + $this->info('Dry run complete. No keys were removed.'); + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) { + $this->warn('Prune cancelled.'); + return self::SUCCESS; + } + + foreach ($candidates as $path) { + File::deleteDirectory($path); + } + + $this->info('Legacy key directories pruned.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/OAuthRotateKeysCommand.php b/app/Console/Commands/OAuthRotateKeysCommand.php index 47c6232..92ed3eb 100644 --- a/app/Console/Commands/OAuthRotateKeysCommand.php +++ b/app/Console/Commands/OAuthRotateKeysCommand.php @@ -42,9 +42,12 @@ class OAuthRotateKeysCommand extends Command if ($archiveDir) { $this->line("Previous keys archived at: {$archiveDir}"); + $this->line('Existing key remains available for token verification until you prune it.'); } $this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}"); + $this->info('Run `php artisan oauth:list-keys` to verify active signing directories.'); + $this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.'); return self::SUCCESS; } @@ -58,7 +61,7 @@ class OAuthRotateKeysCommand extends Command if (File::exists($existingDir)) { $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis'); File::ensureDirectoryExists(dirname($archiveDir)); - File::moveDirectory($existingDir, $archiveDir); + File::copyDirectory($existingDir, $archiveDir); return $archiveDir; } @@ -67,11 +70,11 @@ class OAuthRotateKeysCommand extends Command File::ensureDirectoryExists($archiveDir); if (File::exists($legacyPublic)) { - File::move($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key'); + File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key'); } if (File::exists($legacyPrivate)) { - File::move($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key'); + File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key'); } return $archiveDir; @@ -108,4 +111,3 @@ class OAuthRotateKeysCommand extends Command File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); } } - diff --git a/app/Console/Commands/SendAbandonedCheckoutReminders.php b/app/Console/Commands/SendAbandonedCheckoutReminders.php index 11df770..5b848c4 100644 --- a/app/Console/Commands/SendAbandonedCheckoutReminders.php +++ b/app/Console/Commands/SendAbandonedCheckoutReminders.php @@ -63,19 +63,24 @@ class SendAbandonedCheckoutReminders extends Command $resumeUrl = $this->generateResumeUrl($checkout); if (!$isDryRun) { - Mail::to($checkout->user)->queue( - new AbandonedCheckout( - $checkout->user, - $checkout->package, - $stage, - $resumeUrl - ) - ); + $mailLocale = $checkout->user->preferred_locale ?? config('app.locale'); + + Mail::to($checkout->user) + ->locale($mailLocale) + ->queue( + new AbandonedCheckout( + $checkout->user, + $checkout->package, + $stage, + $resumeUrl + ) + ); $checkout->updateReminderStage($stage); $totalSent++; } else { - $this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$checkout->package->name}"); + $packageName = $checkout->package->getNameForLocale($checkout->user->preferred_locale ?? config('app.locale')); + $this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$packageName}"); } $totalProcessed++; diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 4ab3618..84dec3c 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -7,7 +7,9 @@ use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationM use App\Models\Event; use App\Models\EventType; use App\Models\Tenant; +use App\Models\EventJoinTokenEvent; use App\Support\JoinTokenLayoutRegistry; +use Carbon\Carbon; use BackedEnum; use Filament\Actions; use Filament\Forms\Components\DatePicker; @@ -149,36 +151,89 @@ class EventResource extends Resource ->modalContent(function ($record) { $tokens = $record->joinTokens() ->orderByDesc('created_at') - ->get() - ->map(function ($token) use ($record) { - $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { - return route('tenant.events.join-tokens.layouts.download', [ - 'event' => $record->slug, - 'joinToken' => $token->getKey(), - 'layout' => $layoutId, - 'format' => $format, - ]); - }); + ->get(); - return [ - 'id' => $token->id, - 'label' => $token->label, - 'token' => $token->token, - 'url' => url('/e/' . $token->token), - 'usage_limit' => $token->usage_limit, - 'usage_count' => $token->usage_count, - 'expires_at' => optional($token->expires_at)->toIso8601String(), - 'revoked_at' => optional($token->revoked_at)->toIso8601String(), - 'is_active' => $token->isActive(), - 'created_at' => optional($token->created_at)->toIso8601String(), - 'layouts' => $layouts, - 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ - 'event' => $record->slug, - 'joinToken' => $token->getKey(), - ]), - ]; + if ($tokens->isEmpty()) { + return view('filament.events.join-link', [ + 'event' => $record, + 'tokens' => collect(), + ]); + } + + $tokenIds = $tokens->pluck('id'); + $now = now(); + + $totals = EventJoinTokenEvent::query() + ->selectRaw('event_join_token_id, event_type, COUNT(*) as total') + ->whereIn('event_join_token_id', $tokenIds) + ->groupBy('event_join_token_id', 'event_type') + ->get() + ->groupBy('event_join_token_id'); + + $recent24h = EventJoinTokenEvent::query() + ->selectRaw('event_join_token_id, COUNT(*) as total') + ->whereIn('event_join_token_id', $tokenIds) + ->where('occurred_at', '>=', $now->copy()->subHours(24)) + ->groupBy('event_join_token_id') + ->pluck('total', 'event_join_token_id'); + + $lastSeen = EventJoinTokenEvent::query() + ->whereIn('event_join_token_id', $tokenIds) + ->selectRaw('event_join_token_id, MAX(occurred_at) as last_at') + ->groupBy('event_join_token_id') + ->pluck('last_at', 'event_join_token_id'); + + $tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) { + $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); }); + $analyticsGroup = $totals->get($token->id, collect()); + $analytics = $analyticsGroup->mapWithKeys(function ($row) { + return [$row->event_type => (int) $row->total]; + }); + + $successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0); + $failureCount = (int) ($analytics['invalid_token'] ?? 0) + + (int) ($analytics['token_expired'] ?? 0) + + (int) ($analytics['token_revoked'] ?? 0) + + (int) ($analytics['token_rate_limited'] ?? 0) + + (int) ($analytics['event_not_public'] ?? 0) + + (int) ($analytics['gallery_expired'] ?? 0); + + $lastSeenAt = $lastSeen->get($token->id); + + return [ + 'id' => $token->id, + 'label' => $token->label, + 'token' => $token->token, + 'url' => url('/e/' . $token->token), + 'usage_limit' => $token->usage_limit, + 'usage_count' => $token->usage_count, + 'expires_at' => optional($token->expires_at)->toIso8601String(), + 'revoked_at' => optional($token->revoked_at)->toIso8601String(), + 'is_active' => $token->isActive(), + 'created_at' => optional($token->created_at)->toIso8601String(), + 'layouts' => $layouts, + 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + ]), + 'analytics' => [ + 'success_total' => $successCount, + 'failure_total' => $failureCount, + 'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0), + 'recent_24h' => (int) $recent24h->get($token->id, 0), + 'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null, + ], + ]; + }); + return view('filament.events.join-link', [ 'event' => $record, 'tokens' => $tokens, diff --git a/app/Filament/Resources/RefreshTokenResource.php b/app/Filament/Resources/RefreshTokenResource.php new file mode 100644 index 0000000..7359ddc --- /dev/null +++ b/app/Filament/Resources/RefreshTokenResource.php @@ -0,0 +1,200 @@ +schema([]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('tenant.name') + ->label(__('admin.refresh_tokens.fields.tenant')) + ->searchable(), + Tables\Columns\TextColumn::make('client_id') + ->label(__('admin.refresh_tokens.fields.client')) + ->copyable() + ->toggleable(), + Tables\Columns\TextColumn::make('ip_address') + ->label(__('admin.refresh_tokens.fields.ip_address')) + ->toggleable(), + Tables\Columns\TextColumn::make('user_agent') + ->label(__('admin.refresh_tokens.fields.user_agent')) + ->limit(40) + ->tooltip(fn (RefreshToken $record) => $record->user_agent) + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('created_at') + ->label(__('admin.refresh_tokens.fields.created_at')) + ->since() + ->sortable(), + Tables\Columns\TextColumn::make('last_used_at') + ->label(__('admin.refresh_tokens.fields.last_used_at')) + ->since() + ->sortable() + ->toggleable(), + Tables\Columns\TextColumn::make('expires_at') + ->label(__('admin.refresh_tokens.fields.expires_at')) + ->dateTime() + ->sortable() + ->toggleable(), + Tables\Columns\TextColumn::make('status') + ->label(__('admin.refresh_tokens.fields.status')) + ->badge() + ->formatStateUsing(function (RefreshToken $record): string { + if ($record->revoked_at) { + return __('admin.refresh_tokens.status.revoked'); + } + + if ($record->expires_at && $record->expires_at->isPast()) { + return __('admin.refresh_tokens.status.expired'); + } + + return __('admin.refresh_tokens.status.active'); + }) + ->color(function (RefreshToken $record): string { + if ($record->revoked_at) { + return 'danger'; + } + + if ($record->expires_at && $record->expires_at->isPast()) { + return 'warning'; + } + + return 'success'; + }), + Tables\Columns\TextColumn::make('revoked_reason') + ->label(__('admin.refresh_tokens.fields.revoked_reason')) + ->formatStateUsing(function (?string $state): ?string { + if (! $state) { + return null; + } + + $key = "admin.refresh_tokens.reasons.{$state}"; + $translated = __($key); + + return $translated === $key ? $state : $translated; + }) + ->badge() + ->color('gray') + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('admin.refresh_tokens.filters.status')) + ->options([ + 'active' => __('admin.refresh_tokens.status.active'), + 'revoked' => __('admin.refresh_tokens.status.revoked'), + 'expired' => __('admin.refresh_tokens.status.expired'), + ]) + ->query(function (Builder $query, array $data): Builder { + return match ($data['value'] ?? null) { + 'revoked' => $query->whereNotNull('revoked_at'), + 'expired' => $query->whereNull('revoked_at')->whereNotNull('expires_at')->where('expires_at', '<=', now()), + 'active' => $query->whereNull('revoked_at')->where(function ($inner) { + $inner->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }), + default => $query, + }; + }), + SelectFilter::make('tenant_id') + ->label(__('admin.refresh_tokens.filters.tenant')) + ->relationship('tenant', 'name') + ->searchable(), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + Action::make('revoke') + ->label(__('admin.refresh_tokens.actions.revoke')) + ->icon('heroicon-o-no-symbol') + ->color('danger') + ->visible(fn (RefreshToken $record): bool => $record->isActive()) + ->form([ + Forms\Components\Select::make('reason') + ->label(__('admin.refresh_tokens.fields.revoked_reason')) + ->options([ + 'manual' => __('admin.refresh_tokens.reasons.manual'), + 'operator' => __('admin.refresh_tokens.reasons.operator'), + ]) + ->default('manual') + ->required(), + Forms\Components\Textarea::make('note') + ->label(__('admin.refresh_tokens.fields.note')) + ->rows(2) + ->maxLength(255), + ]) + ->requiresConfirmation() + ->action(function (RefreshToken $record, array $data): void { + $note = $data['note'] ?? null; + + $record->revoke( + $data['reason'] ?? 'manual', + auth()->id(), + request(), + $note ? ['note' => $note] : [] + ); + }), + ]) + ->bulkActions([]); + } + + public static function getRelations(): array + { + return [ + AuditsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRefreshTokens::route('/'), + 'view' => Pages\ViewRefreshToken::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php b/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php new file mode 100644 index 0000000..1d16109 --- /dev/null +++ b/app/Filament/Resources/RefreshTokenResource/Pages/ListRefreshTokens.php @@ -0,0 +1,17 @@ +label(__('admin.refresh_tokens.actions.revoke')) + ->icon('heroicon-o-no-symbol') + ->color('danger') + ->visible(fn (): bool => $this->record->isActive()) + ->form([ + Forms\Components\Select::make('reason') + ->label(__('admin.refresh_tokens.fields.revoked_reason')) + ->options([ + 'manual' => __('admin.refresh_tokens.reasons.manual'), + 'operator' => __('admin.refresh_tokens.reasons.operator'), + ]) + ->default('manual') + ->required(), + Forms\Components\Textarea::make('note') + ->label(__('admin.refresh_tokens.fields.note')) + ->rows(2) + ->maxLength(255), + ]) + ->requiresConfirmation() + ->action(function (array $data): void { + $note = $data['note'] ?? null; + + $this->record->revoke( + $data['reason'] ?? 'manual', + auth()->id(), + request(), + $note ? ['note' => $note] : [] + ); + + $this->record->refresh(); + + $this->notify('success', __('admin.refresh_tokens.notifications.revoked')); + }), + ]; + } +} + diff --git a/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php b/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php new file mode 100644 index 0000000..60b34f9 --- /dev/null +++ b/app/Filament/Resources/RefreshTokenResource/RelationManagers/AuditsRelationManager.php @@ -0,0 +1,69 @@ +recordTitleAttribute('event') + ->defaultSort('created_at', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('created_at') + ->label(__('admin.refresh_tokens.audit.performed_at')) + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('event') + ->label(__('admin.refresh_tokens.audit.event')) + ->badge() + ->formatStateUsing(function (?string $state): ?string { + if (! $state) { + return null; + } + + $key = "admin.refresh_tokens.audit.events.{$state}"; + $translated = __($key); + + return $translated === $key ? $state : $translated; + }), + Tables\Columns\TextColumn::make('performedBy.name') + ->label(__('admin.refresh_tokens.audit.performed_by')) + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('ip_address') + ->label(__('admin.refresh_tokens.audit.ip_address')) + ->toggleable(), + Tables\Columns\TextColumn::make('context') + ->label(__('admin.refresh_tokens.audit.context')) + ->formatStateUsing(function ($state): string { + if (! is_array($state) || empty($state)) { + return '—'; + } + + return collect($state) + ->filter(fn ($value) => filled($value)) + ->map(function ($value, $key) { + if (is_array($value)) { + $value = json_encode($value); + } + + return "{$key}: {$value}"; + }) + ->implode(', '); + }) + ->wrap() + ->toggleable(), + ]) + ->filters([]) + ->paginated([10, 25, 50]) + ->emptyStateHeading(__('admin.refresh_tokens.audit.empty.heading')) + ->emptyStateDescription(__('admin.refresh_tokens.audit.empty.description')); + } +} diff --git a/app/Filament/Tenant/Resources/EventResource.php b/app/Filament/Tenant/Resources/EventResource.php index 860553b..3005e5d 100644 --- a/app/Filament/Tenant/Resources/EventResource.php +++ b/app/Filament/Tenant/Resources/EventResource.php @@ -7,6 +7,8 @@ use App\Support\JoinTokenLayoutRegistry; use App\Support\TenantOnboardingState; use App\Models\Event; use App\Models\EventType; +use App\Models\EventJoinTokenEvent; +use Carbon\Carbon; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; @@ -149,36 +151,89 @@ class EventResource extends Resource ->modalContent(function ($record) { $tokens = $record->joinTokens() ->orderByDesc('created_at') - ->get() - ->map(function ($token) use ($record) { - $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { - return route('tenant.events.join-tokens.layouts.download', [ - 'event' => $record->slug, - 'joinToken' => $token->getKey(), - 'layout' => $layoutId, - 'format' => $format, - ]); - }); + ->get(); - return [ - 'id' => $token->id, - 'label' => $token->label, - 'token' => $token->token, - 'url' => url('/e/'.$token->token), - 'usage_limit' => $token->usage_limit, - 'usage_count' => $token->usage_count, - 'expires_at' => optional($token->expires_at)->toIso8601String(), - 'revoked_at' => optional($token->revoked_at)->toIso8601String(), - 'is_active' => $token->isActive(), - 'created_at' => optional($token->created_at)->toIso8601String(), - 'layouts' => $layouts, - 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ - 'event' => $record->slug, - 'joinToken' => $token->getKey(), - ]), - ]; + if ($tokens->isEmpty()) { + return view('filament.events.join-link', [ + 'event' => $record, + 'tokens' => collect(), + ]); + } + + $tokenIds = $tokens->pluck('id'); + $now = now(); + + $totals = EventJoinTokenEvent::query() + ->selectRaw('event_join_token_id, event_type, COUNT(*) as total') + ->whereIn('event_join_token_id', $tokenIds) + ->groupBy('event_join_token_id', 'event_type') + ->get() + ->groupBy('event_join_token_id'); + + $recent24h = EventJoinTokenEvent::query() + ->selectRaw('event_join_token_id, COUNT(*) as total') + ->whereIn('event_join_token_id', $tokenIds) + ->where('occurred_at', '>=', $now->copy()->subHours(24)) + ->groupBy('event_join_token_id') + ->pluck('total', 'event_join_token_id'); + + $lastSeen = EventJoinTokenEvent::query() + ->whereIn('event_join_token_id', $tokenIds) + ->selectRaw('event_join_token_id, MAX(occurred_at) as last_at') + ->groupBy('event_join_token_id') + ->pluck('last_at', 'event_join_token_id'); + + $tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) { + $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); }); + $analyticsGroup = $totals->get($token->id, collect()); + $analytics = $analyticsGroup->mapWithKeys(function ($row) { + return [$row->event_type => (int) $row->total]; + }); + + $successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0); + $failureCount = (int) ($analytics['invalid_token'] ?? 0) + + (int) ($analytics['token_expired'] ?? 0) + + (int) ($analytics['token_revoked'] ?? 0) + + (int) ($analytics['token_rate_limited'] ?? 0) + + (int) ($analytics['event_not_public'] ?? 0) + + (int) ($analytics['gallery_expired'] ?? 0); + + $lastSeenAt = $lastSeen->get($token->id); + + return [ + 'id' => $token->id, + 'label' => $token->label, + 'token' => $token->token, + 'url' => url('/e/'.$token->token), + 'usage_limit' => $token->usage_limit, + 'usage_count' => $token->usage_count, + 'expires_at' => optional($token->expires_at)->toIso8601String(), + 'revoked_at' => optional($token->revoked_at)->toIso8601String(), + 'is_active' => $token->isActive(), + 'created_at' => optional($token->created_at)->toIso8601String(), + 'layouts' => $layouts, + 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + ]), + 'analytics' => [ + 'success_total' => $successCount, + 'failure_total' => $failureCount, + 'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0), + 'recent_24h' => (int) $recent24h->get($token->id, 0), + 'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null, + ], + ]; + }); + return view('filament.events.join-link', [ 'event' => $record, 'tokens' => $tokens, diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 4a48324..2522b8f 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -10,9 +10,11 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; use App\Support\ImageHelper; +use App\Services\Analytics\JoinTokenAnalyticsRecorder; use App\Services\EventJoinTokenService; use App\Services\Storage\EventStorageManager; use App\Models\Event; @@ -24,9 +26,12 @@ use App\Models\EventMediaAsset; class EventPublicController extends BaseController { + private const SIGNED_URL_TTL_SECONDS = 1800; + public function __construct( private readonly EventJoinTokenService $joinTokenService, private readonly EventStorageManager $eventStorageManager, + private readonly JoinTokenAnalyticsRecorder $analyticsRecorder, ) { } @@ -40,34 +45,65 @@ class EventPublicController extends BaseController $joinToken = $this->joinTokenService->findToken($token, true); if (! $joinToken) { - return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [ - 'token' => Str::limit($token, 12), - ]); + return $this->handleTokenFailure( + $request, + $rateLimiterKey, + 'invalid_token', + Response::HTTP_NOT_FOUND, + [ + 'token' => Str::limit($token, 12), + ], + $token + ); } if ($joinToken->revoked_at !== null) { - return $this->handleTokenFailure($request, $rateLimiterKey, 'token_revoked', Response::HTTP_GONE, [ - 'token' => Str::limit($token, 12), - ]); + return $this->handleTokenFailure( + $request, + $rateLimiterKey, + 'token_revoked', + Response::HTTP_GONE, + [ + 'token' => Str::limit($token, 12), + ], + $token, + $joinToken + ); } if ($joinToken->expires_at !== null) { $expiresAt = CarbonImmutable::parse($joinToken->expires_at); if ($expiresAt->isPast()) { - return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [ - 'token' => Str::limit($token, 12), - 'expired_at' => $expiresAt->toAtomString(), - ]); + return $this->handleTokenFailure( + $request, + $rateLimiterKey, + 'token_expired', + Response::HTTP_GONE, + [ + 'token' => Str::limit($token, 12), + 'expired_at' => $expiresAt->toAtomString(), + ], + $token, + $joinToken + ); } } if ($joinToken->usage_limit !== null && $joinToken->usage_count >= $joinToken->usage_limit) { - return $this->handleTokenFailure($request, $rateLimiterKey, 'token_expired', Response::HTTP_GONE, [ - 'token' => Str::limit($token, 12), - 'usage_count' => $joinToken->usage_count, - 'usage_limit' => $joinToken->usage_limit, - ]); + return $this->handleTokenFailure( + $request, + $rateLimiterKey, + 'token_expired', + Response::HTTP_GONE, + [ + 'token' => Str::limit($token, 12), + 'usage_count' => $joinToken->usage_count, + 'usage_limit' => $joinToken->usage_limit, + ], + $token, + $joinToken + ); } $columns = array_unique(array_merge($columns, ['status'])); @@ -77,10 +113,18 @@ class EventPublicController extends BaseController ->first($columns); if (! $event) { - return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [ - 'token' => Str::limit($token, 12), - 'reason' => 'event_missing', - ]); + return $this->handleTokenFailure( + $request, + $rateLimiterKey, + 'invalid_token', + Response::HTTP_NOT_FOUND, + [ + 'token' => Str::limit($token, 12), + 'reason' => 'event_missing', + ], + $token, + $joinToken + ); } if (($event->status ?? null) !== 'published') { @@ -90,6 +134,18 @@ class EventPublicController extends BaseController 'ip' => $request->ip(), ]); + $this->recordTokenEvent( + $joinToken, + $request, + 'event_not_public', + [ + 'token' => Str::limit($token, 12), + 'event_id' => $event->id ?? null, + ], + $token, + Response::HTTP_FORBIDDEN + ); + return response()->json([ 'error' => [ 'code' => 'event_not_public', @@ -104,6 +160,22 @@ class EventPublicController extends BaseController unset($event->status); } + $this->recordTokenEvent( + $joinToken, + $request, + 'access_granted', + [ + 'event_id' => $event->id ?? null, + ], + $token, + Response::HTTP_OK + ); + + $throttleResponse = $this->enforceAccessThrottle($joinToken, $request, $token); + if ($throttleResponse instanceof JsonResponse) { + return $throttleResponse; + } + return [$event, $joinToken]; } @@ -134,6 +206,18 @@ class EventPublicController extends BaseController $expiresAt = optional($event->eventPackage)->gallery_expires_at; if ($expiresAt instanceof Carbon && $expiresAt->isPast()) { + $this->recordTokenEvent( + $joinToken, + $request, + 'gallery_expired', + [ + 'event_id' => $event->id, + 'expired_at' => $expiresAt->toIso8601String(), + ], + $token, + Response::HTTP_GONE + ); + return response()->json([ 'error' => [ 'code' => 'gallery_expired', @@ -143,16 +227,47 @@ class EventPublicController extends BaseController ], Response::HTTP_GONE); } + $this->recordTokenEvent( + $joinToken, + $request, + 'gallery_access_granted', + [ + 'event_id' => $event->id, + ], + $token, + Response::HTTP_OK + ); + return [$event, $joinToken]; } - private function handleTokenFailure(Request $request, string $rateLimiterKey, string $code, int $status, array $context = []): JsonResponse + private function handleTokenFailure( + Request $request, + string $rateLimiterKey, + string $code, + int $status, + array $context = [], + ?string $rawToken = null, + ?EventJoinToken $joinToken = null + ): JsonResponse { - if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) { + $failureLimit = max(1, (int) config('join_tokens.failure_limit', 10)); + $failureDecay = max(1, (int) config('join_tokens.failure_decay_minutes', 5)); + + if (RateLimiter::tooManyAttempts($rateLimiterKey, $failureLimit)) { Log::warning('Join token rate limit exceeded', array_merge([ 'ip' => $request->ip(), ], $context)); + $this->recordTokenEvent( + $joinToken, + $request, + 'token_rate_limited', + array_merge($context, ['rate_limiter_key' => $rateLimiterKey]), + $rawToken, + Response::HTTP_TOO_MANY_REQUESTS + ); + return response()->json([ 'error' => [ 'code' => 'token_rate_limited', @@ -161,13 +276,22 @@ class EventPublicController extends BaseController ], Response::HTTP_TOO_MANY_REQUESTS); } - RateLimiter::hit($rateLimiterKey, 300); + RateLimiter::hit($rateLimiterKey, $failureDecay * 60); Log::notice('Join token access denied', array_merge([ 'code' => $code, 'ip' => $request->ip(), ], $context)); + $this->recordTokenEvent( + $joinToken, + $request, + $code, + array_merge($context, ['rate_limiter_key' => $rateLimiterKey]), + $rawToken, + $status + ); + return response()->json([ 'error' => [ 'code' => $code, @@ -186,6 +310,89 @@ class EventPublicController extends BaseController }; } + private function recordTokenEvent( + ?EventJoinToken $joinToken, + Request $request, + string $eventType, + array $context = [], + ?string $rawToken = null, + ?int $status = null + ): void { + $this->analyticsRecorder->record($joinToken, $eventType, $request, $context, $rawToken, $status); + } + + private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse + { + $limit = (int) config('join_tokens.access_limit', 0); + if ($limit <= 0) { + return null; + } + + $decay = max(1, (int) config('join_tokens.access_decay_minutes', 1)); + $key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip()); + + if (RateLimiter::tooManyAttempts($key, $limit)) { + $this->recordTokenEvent( + $joinToken, + $request, + 'access_rate_limited', + [ + 'limit' => $limit, + 'decay_minutes' => $decay, + ], + $rawToken, + Response::HTTP_TOO_MANY_REQUESTS + ); + + return response()->json([ + 'error' => [ + 'code' => 'access_rate_limited', + 'message' => 'Too many requests. Please slow down.', + ], + ], Response::HTTP_TOO_MANY_REQUESTS); + } + + RateLimiter::hit($key, $decay * 60); + + return null; + } + + private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse + { + $limit = (int) config('join_tokens.download_limit', 0); + if ($limit <= 0) { + return null; + } + + $decay = max(1, (int) config('join_tokens.download_decay_minutes', 1)); + $key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip()); + + if (RateLimiter::tooManyAttempts($key, $limit)) { + $this->recordTokenEvent( + $joinToken, + $request, + 'download_rate_limited', + [ + 'limit' => $limit, + 'decay_minutes' => $decay, + ], + $rawToken, + Response::HTTP_TOO_MANY_REQUESTS + ); + + return response()->json([ + 'error' => [ + 'code' => 'download_rate_limited', + 'message' => 'Download rate limit exceeded. Please wait a moment.', + ], + ], Response::HTTP_TOO_MANY_REQUESTS); + } + + RateLimiter::hit($key, $decay * 60); + + return null; + } + private function getLocalized($value, $locale, $default = '') { if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); @@ -288,23 +495,50 @@ class EventPublicController extends BaseController private function makeGalleryPhotoResource(Photo $photo, string $token): array { - $thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null); - $full = $this->toPublicUrl($photo->file_path ?? null); + $thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail'); + $fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full'); + $downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo); return [ 'id' => $photo->id, - 'thumbnail_url' => $thumbnail, - 'full_url' => $full, - 'download_url' => route('api.v1.gallery.photos.download', [ - 'token' => $token, - 'photo' => $photo->id, - ]), + 'thumbnail_url' => $thumbnailUrl ?? $fullUrl, + 'full_url' => $fullUrl, + 'download_url' => $downloadUrl, 'likes_count' => $photo->likes_count, 'guest_name' => $photo->guest_name, 'created_at' => $photo->created_at?->toIso8601String(), ]; } + private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string + { + if (! in_array($variant, ['thumbnail', 'full'], true)) { + return null; + } + + return URL::temporarySignedRoute( + 'api.v1.gallery.photos.asset', + now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), + [ + 'token' => $token, + 'photo' => $photo->id, + 'variant' => $variant, + ] + ); + } + + private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string + { + return URL::temporarySignedRoute( + 'api.v1.gallery.photos.download', + now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), + [ + 'token' => $token, + 'photo' => $photo->id, + ] + ); + } + public function gallery(Request $request, string $token) { $locale = $request->query('locale', app()->getLocale()); @@ -316,7 +550,11 @@ class EventPublicController extends BaseController } /** @var array{0: Event, 1: EventJoinToken} $resolved */ - [$event] = $resolved; + [$event, $joinToken] = $resolved; + + if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) { + return $downloadResponse; + } $branding = $this->buildGalleryBranding($event); $expiresAt = optional($event->eventPackage)->gallery_expires_at; @@ -342,7 +580,11 @@ class EventPublicController extends BaseController } /** @var array{0: Event, 1: EventJoinToken} $resolved */ - [$event] = $resolved; + [$event, $joinToken] = $resolved; + + if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) { + return $downloadResponse; + } $limit = (int) $request->query('limit', 30); $limit = max(1, min($limit, 60)); @@ -389,6 +631,39 @@ class EventPublicController extends BaseController ]); } + public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant) + { + $resolved = $this->resolveGalleryEvent($request, $token); + + if ($resolved instanceof JsonResponse) { + return $resolved; + } + + /** @var array{0: Event, 1: EventJoinToken} $resolved */ + [$event] = $resolved; + + $record = Photo::with('mediaAsset') + ->where('id', $photo) + ->where('event_id', $event->id) + ->where('status', 'approved') + ->first(); + + if (! $record) { + return response()->json([ + 'error' => [ + 'code' => 'photo_not_found', + 'message' => 'The requested photo is no longer available.', + ], + ], Response::HTTP_NOT_FOUND); + } + + $variantPreference = $variant === 'thumbnail' + ? ['thumbnail', 'original'] + : ['original']; + + return $this->streamGalleryPhoto($event, $record, $variantPreference, 'inline'); + } + public function galleryPhotoDownload(Request $request, string $token, int $photo) { $resolved = $this->resolveGalleryEvent($request, $token); @@ -415,52 +690,7 @@ class EventPublicController extends BaseController ], Response::HTTP_NOT_FOUND); } - $asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); - - if ($asset) { - $disk = $asset->disk ?? config('filesystems.default'); - $path = $asset->path ?? $record->file_path; - - try { - if ($path && Storage::disk($disk)->exists($path)) { - $stream = Storage::disk($disk)->readStream($path); - - if ($stream) { - $extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg'; - $filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension); - $mime = $asset->mime_type ?? 'image/jpeg'; - - return response()->streamDownload(function () use ($stream) { - fpassthru($stream); - fclose($stream); - }, $filename, [ - 'Content-Type' => $mime, - ]); - } - } - } catch (\Throwable $e) { - Log::warning('Gallery photo download failed', [ - 'event_id' => $event->id, - 'photo_id' => $record->id, - 'disk' => $asset->disk ?? null, - 'path' => $asset->path ?? null, - 'error' => $e->getMessage(), - ]); - } - } - - $publicUrl = $this->toPublicUrl($record->file_path ?? null); - - if ($publicUrl) { - return redirect()->away($publicUrl); - } - - return response()->json([ - 'error' => [ - 'code' => 'photo_unavailable', - 'message' => 'The requested photo could not be downloaded.', - ], - ], Response::HTTP_NOT_FOUND); + return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment'); } public function event(Request $request, string $token) @@ -518,6 +748,160 @@ class EventPublicController extends BaseController ])->header('Cache-Control', 'no-store'); } + private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition) + { + foreach ($variantPreference as $variant) { + [$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant); + + if (! $path) { + continue; + } + + $diskName = $this->resolveStorageDisk($disk); + + try { + $storage = Storage::disk($diskName); + } catch (\Throwable $e) { + Log::warning('Gallery asset disk unavailable', [ + 'event_id' => $event->id, + 'photo_id' => $record->id, + 'disk' => $diskName, + 'error' => $e->getMessage(), + ]); + continue; + } + + foreach ($this->storagePathCandidates($path) as $candidate) { + try { + if (! $storage->exists($candidate)) { + continue; + } + + $stream = $storage->readStream($candidate); + if (! $stream) { + continue; + } + + $size = null; + try { + $size = $storage->size($candidate); + } catch (\Throwable $e) { + $size = null; + } + + if (! $mime) { + try { + $mime = $storage->mimeType($candidate); + } catch (\Throwable $e) { + $mime = null; + } + } + + $extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg'); + $suffix = $variant === 'thumbnail' ? '-thumb' : ''; + $filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg'); + + $headers = [ + 'Content-Type' => $mime ?? 'image/jpeg', + 'Cache-Control' => $disposition === 'attachment' + ? 'no-store, max-age=0' + : 'private, max-age='.self::SIGNED_URL_TTL_SECONDS, + 'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"', + ]; + + if ($size) { + $headers['Content-Length'] = $size; + } + + return response()->stream(function () use ($stream) { + fpassthru($stream); + fclose($stream); + }, 200, $headers); + } catch (\Throwable $e) { + Log::warning('Gallery asset stream error', [ + 'event_id' => $event->id, + 'photo_id' => $record->id, + 'disk' => $diskName, + 'path' => $candidate, + 'variant' => $variant, + 'error' => $e->getMessage(), + ]); + } + } + } + + $fallbackUrl = $this->toPublicUrl($record->file_path ?? null); + + if ($fallbackUrl) { + return redirect()->away($fallbackUrl); + } + + return response()->json([ + 'error' => [ + 'code' => 'photo_unavailable', + 'message' => 'The requested photo could not be loaded.', + ], + ], Response::HTTP_NOT_FOUND); + } + + private function resolvePhotoVariant(Photo $record, string $variant): array + { + if ($variant === 'thumbnail') { + $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first(); + $disk = $asset?->disk ?? $record->mediaAsset?->disk; + $path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path); + $mime = $asset?->mime_type ?? 'image/jpeg'; + } else { + $asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); + $disk = $asset?->disk ?? $record->mediaAsset?->disk; + $path = $asset?->path ?? ($record->file_path ?? null); + $mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg'); + } + + return [ + $disk ?: config('filesystems.default', 'public'), + $path, + $mime, + ]; + } + + private function resolveStorageDisk(?string $disk): string + { + $disk = $disk ?: config('filesystems.default', 'public'); + + if (! config("filesystems.disks.{$disk}")) { + return config('filesystems.default', 'public'); + } + + return $disk; + } + + private function storagePathCandidates(string $path): array + { + $normalized = str_replace('\\', '/', $path); + $candidates = [$normalized]; + + $trimmed = ltrim($normalized, '/'); + if ($trimmed !== $normalized) { + $candidates[] = $trimmed; + } + + if (str_starts_with($trimmed, 'storage/')) { + $candidates[] = substr($trimmed, strlen('storage/')); + } + + if (str_starts_with($trimmed, 'public/')) { + $candidates[] = substr($trimmed, strlen('public/')); + } + + $needle = '/storage/app/public/'; + if (str_contains($normalized, $needle)) { + $candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle)); + } + + return array_values(array_unique(array_filter($candidates))); + } + public function stats(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); @@ -526,7 +910,7 @@ class EventPublicController extends BaseController return $result; } - [$event] = $result; + [$event, $joinToken] = $result; $eventId = $event->id; $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); @@ -866,6 +1250,19 @@ class EventPublicController extends BaseController // Per-device cap per event (MVP: 50) $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); if ($deviceCount >= 50) { + $this->recordTokenEvent( + $joinToken, + $request, + 'upload_device_limit', + [ + 'event_id' => $eventId, + 'device_id' => $deviceId, + 'device_count' => $deviceCount, + ], + $token, + Response::HTTP_TOO_MANY_REQUESTS + ); + return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429); } @@ -936,11 +1333,26 @@ class EventPublicController extends BaseController ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); - return response()->json([ + $response = response()->json([ 'id' => $photoId, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, ], 201); + + $this->recordTokenEvent( + $joinToken, + $request, + 'upload_completed', + [ + 'event_id' => $eventId, + 'photo_id' => $photoId, + 'device_id' => $deviceId, + ], + $token, + Response::HTTP_CREATED + ); + + return $response; } /** @@ -1201,7 +1613,6 @@ class EventPublicController extends BaseController ->header('Cache-Control', 'no-store') ->header('ETag', $etag); } -} private function resolveDiskUrl(string $disk, string $path): string { @@ -1217,3 +1628,5 @@ class EventPublicController extends BaseController return $path; } } + +} diff --git a/app/Http/Controllers/Api/StripeWebhookController.php b/app/Http/Controllers/Api/StripeWebhookController.php index c79d110..44f442f 100644 --- a/app/Http/Controllers/Api/StripeWebhookController.php +++ b/app/Http/Controllers/Api/StripeWebhookController.php @@ -3,10 +3,11 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Models\PackagePurchase; use App\Models\EventPackage; +use App\Models\PackagePurchase; use App\Models\TenantPackage; use App\Models\User; +use App\Services\Checkout\CheckoutWebhookService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -15,6 +16,10 @@ use Stripe\Webhook; class StripeWebhookController extends Controller { + public function __construct(private CheckoutWebhookService $checkoutWebhooks) + { + } + public function handleWebhook(Request $request) { $payload = $request->getContent(); @@ -23,7 +28,9 @@ class StripeWebhookController extends Controller try { $event = Webhook::constructEvent( - $payload, $sigHeader, $endpointSecret + $payload, + $sigHeader, + $endpointSecret ); } catch (SignatureVerificationException $e) { return response()->json(['error' => 'Invalid signature'], 400); @@ -31,54 +38,81 @@ class StripeWebhookController extends Controller return response()->json(['error' => 'Invalid payload'], 400); } - // Handle the event - switch ($event['type']) { + $eventArray = method_exists($event, 'toArray') ? $event->toArray() : (array) $event; + + if ($this->checkoutWebhooks->handleStripeEvent($eventArray)) { + return response()->json(['status' => 'success'], 200); + } + + // Legacy handlers for legacy marketing checkout + return $this->handleLegacyEvent($eventArray); + } + + private function handleLegacyEvent(array $event) + { + $type = $event['type'] ?? null; + + switch ($type) { case 'payment_intent.succeeded': - $paymentIntent = $event['data']['object']; + $paymentIntent = $event['data']['object'] ?? []; $this->handlePaymentIntentSucceeded($paymentIntent); break; case 'invoice.paid': - $invoice = $event['data']['object']; + $invoice = $event['data']['object'] ?? []; $this->handleInvoicePaid($invoice); break; default: - Log::info('Unhandled Stripe event', ['type' => $event['type']]); + Log::info('Unhandled Stripe event', ['type' => $type]); } return response()->json(['status' => 'success'], 200); } - private function handlePaymentIntentSucceeded(array $paymentIntent) + private function handlePaymentIntentSucceeded(array $paymentIntent): void { - $metadata = $paymentIntent['metadata']; - $packageId = $metadata['package_id']; - $type = $metadata['type']; + $metadata = $paymentIntent['metadata'] ?? []; + $packageId = $metadata['package_id'] ?? null; + $type = $metadata['type'] ?? null; + + if (! $packageId || ! $type) { + Log::warning('Stripe intent missing metadata payload', ['metadata' => $metadata]); + return; + } DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) { - // Create purchase record $purchase = PackagePurchase::create([ 'package_id' => $packageId, 'type' => $type, 'provider_id' => 'stripe', - 'transaction_id' => $paymentIntent['id'], - 'price' => $paymentIntent['amount_received'] / 100, + 'transaction_id' => $paymentIntent['id'] ?? null, + 'price' => isset($paymentIntent['amount_received']) + ? $paymentIntent['amount_received'] / 100 + : 0, 'metadata' => $metadata, ]); if ($type === 'endcustomer_event') { - $eventId = $metadata['event_id']; + $eventId = $metadata['event_id'] ?? null; + if (! $eventId) { + return; + } + EventPackage::create([ 'event_id' => $eventId, 'package_id' => $packageId, 'package_purchase_id' => $purchase->id, 'used_photos' => 0, 'used_guests' => 0, - 'expires_at' => now()->addDays(30), // Default, or from package + 'expires_at' => now()->addDays(30), ]); } elseif ($type === 'reseller_subscription') { - $tenantId = $metadata['tenant_id']; + $tenantId = $metadata['tenant_id'] ?? null; + if (! $tenantId) { + return; + } + TenantPackage::create([ 'tenant_id' => $tenantId, 'package_id' => $packageId, @@ -88,59 +122,60 @@ class StripeWebhookController extends Controller 'expires_at' => now()->addYear(), ]); - $user = User::find($metadata['user_id']); - if ($user) { - $user->update(['role' => 'tenant_admin']); - } - } - }); - } - - private function handleInvoicePaid(array $invoice) - { - $subscription = $invoice['subscription']; - $metadata = $subscription['metadata'] ?? []; - - if (isset($metadata['tenant_id'])) { - $tenantId = $metadata['tenant_id']; - $packageId = $metadata['package_id']; - - // Renew or create tenant package - $tenantPackage = TenantPackage::where('tenant_id', $tenantId) - ->where('package_id', $packageId) - ->where('stripe_subscription_id', $subscription) - ->first(); - - if ($tenantPackage) { - $tenantPackage->update([ - 'active' => true, - 'expires_at' => now()->addYear(), - ]); - } else { - TenantPackage::create([ - 'tenant_id' => $tenantId, - 'package_id' => $packageId, - 'stripe_subscription_id' => $subscription, - 'used_events' => 0, - 'active' => true, - 'expires_at' => now()->addYear(), - ]); - $user = User::find($metadata['user_id'] ?? null); if ($user) { $user->update(['role' => 'tenant_admin']); } } - - // Create purchase record - PackagePurchase::create([ - 'package_id' => $packageId, - 'type' => 'reseller_subscription', - 'provider_id' => 'stripe', - 'transaction_id' => $invoice['id'], - 'price' => $invoice['amount_paid'] / 100, - 'metadata' => $metadata, - ]); - } + }); } -} \ No newline at end of file + + private function handleInvoicePaid(array $invoice): void + { + $subscription = $invoice['subscription'] ?? null; + $metadata = $subscription['metadata'] ?? []; + + if (! isset($metadata['tenant_id'], $metadata['package_id'])) { + return; + } + + $tenantId = $metadata['tenant_id']; + $packageId = $metadata['package_id']; + + $tenantPackage = TenantPackage::where('tenant_id', $tenantId) + ->where('package_id', $packageId) + ->where('stripe_subscription_id', $subscription) + ->first(); + + if ($tenantPackage) { + $tenantPackage->update([ + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + } else { + TenantPackage::create([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'stripe_subscription_id' => $subscription, + 'used_events' => 0, + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + + $user = User::find($metadata['user_id'] ?? null); + if ($user) { + $user->update(['role' => 'tenant_admin']); + } + } + + PackagePurchase::create([ + 'package_id' => $packageId, + 'type' => 'reseller_subscription', + 'provider_id' => 'stripe', + 'transaction_id' => $invoice['id'] ?? null, + 'price' => isset($invoice['amount_paid']) ? $invoice['amount_paid'] / 100 : 0, + 'metadata' => $metadata, + ]); + } +} + diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 7fc6d63..e5861e0 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -9,6 +9,7 @@ use App\Models\Event; use App\Models\Photo; use App\Support\ImageHelper; use App\Services\Storage\EventStorageManager; +use App\Jobs\ProcessPhotoSecurityScan; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -157,6 +158,8 @@ class PhotoController extends Controller [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); + ProcessPhotoSecurityScan::dispatch($photo->id); + $photo->load('event')->loadCount('likes'); return response()->json([ diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index eb7976e..ca6068f 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -102,7 +102,9 @@ class RegisteredUserController extends Controller event(new Registered($user)); // Send Welcome Email - Mail::to($user)->queue(new \App\Mail\Welcome($user)); + Mail::to($user) + ->locale($user->preferred_locale ?? app()->getLocale()) + ->queue(new \App\Mail\Welcome($user)); if ($request->filled('package_id')) { $package = \App\Models\Package::find($request->package_id); diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 49df4e9..4344fc1 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -123,7 +123,9 @@ class CheckoutController extends Controller $user->sendEmailVerificationNotification(); // Willkommens-E-Mail senden - Mail::to($user)->queue(new Welcome($user)); + Mail::to($user) + ->locale($user->preferred_locale ?? app()->getLocale()) + ->queue(new Welcome($user)); }); return response()->json([ diff --git a/app/Http/Controllers/CheckoutGoogleController.php b/app/Http/Controllers/CheckoutGoogleController.php index 43336c4..c9542b2 100644 --- a/app/Http/Controllers/CheckoutGoogleController.php +++ b/app/Http/Controllers/CheckoutGoogleController.php @@ -91,7 +91,9 @@ class CheckoutGoogleController extends Controller $tenant = $this->createTenantForUser($user, $googleUser->getName(), $email); try { - Mail::to($user)->queue(new Welcome($user)); + Mail::to($user) + ->locale($user->preferred_locale ?? app()->getLocale()) + ->queue(new Welcome($user)); } catch (\Throwable $exception) { Log::warning('Failed to queue welcome mail after Google signup', [ 'user_id' => $user->id, diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index b1efe18..9a0ee6a 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -60,14 +60,28 @@ class MarketingController extends Controller 'message' => 'required|string|max:1000', ]); - Mail::raw("Kontakt-Anfrage von {$request->name} ({$request->email}): {$request->message}", function ($message) use ($request) { - $message->to('admin@fotospiel.de') - ->subject('Neue Kontakt-Anfrage'); - }); + $locale = app()->getLocale(); + $contactAddress = config('mail.contact_address', config('mail.from.address')) ?: 'admin@fotospiel.de'; - Mail::to($request->email)->queue(new ContactConfirmation($request->name)); + Mail::raw( + __('emails.contact.body', [ + 'name' => $request->name, + 'email' => $request->email, + 'message' => $request->message, + ], $locale), + function ($message) use ($request, $contactAddress, $locale) { + $message->to($contactAddress) + ->subject(__('emails.contact.subject', [], $locale)); + } + ); - return redirect()->back()->with('success', 'Nachricht gesendet!'); + Mail::to($request->email) + ->locale($locale) + ->queue(new ContactConfirmation($request->name)); + + return redirect() + ->back() + ->with('success', __('marketing.contact.success', [], $locale)); } public function contactView() diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index c440597..a83f441 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -141,7 +141,6 @@ class OAuthController extends Controller if (! $tenant) { Log::error('[OAuth] Tenant not found during token issuance', [ 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, 'tenant_id' => $tenantId, ]); @@ -181,7 +180,6 @@ class OAuthController extends Controller if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) { Log::warning('[OAuth] Authorization code missing or expired', [ 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, ]); return $this->errorResponse('Invalid or expired authorization code', 400); @@ -192,7 +190,7 @@ class OAuthController extends Controller if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) { Log::warning('[OAuth] Authorization code validation failed', [ 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, + 'oauth_code_id' => $oauthCode?->id, ]); return $this->errorResponse('Invalid authorization code', 400); @@ -222,7 +220,7 @@ class OAuthController extends Controller if (! $tenant) { Log::error('[OAuth] Tenant not found during token issuance', [ 'client_id' => $request->client_id, - 'refresh_token_id' => $storedRefreshToken->id, + 'oauth_code_id' => $oauthCode->id ?? null, 'tenant_id' => $tenantId, ]); @@ -271,16 +269,33 @@ class OAuthController extends Controller return $this->errorResponse('Invalid refresh token', 400); } + $storedRefreshToken->recordAudit('refresh_attempt', [ + 'client_id' => $request->client_id, + ], null, $request); + if ($storedRefreshToken->client_id && $storedRefreshToken->client_id !== $request->client_id) { + $storedRefreshToken->recordAudit('client_mismatch', [ + 'expected_client' => $storedRefreshToken->client_id, + 'provided_client' => $request->client_id, + ], null, $request); + return $this->errorResponse('Refresh token does not match client', 400); } if ($storedRefreshToken->expires_at && $storedRefreshToken->expires_at->isPast()) { - $storedRefreshToken->update(['revoked_at' => now()]); + $storedRefreshToken->revoke('expired', null, $request, [ + 'expired_at' => $storedRefreshToken->expires_at?->toIso8601String(), + ]); + return $this->errorResponse('Refresh token expired', 400); } if (! Hash::check($refreshTokenSecret, $storedRefreshToken->token)) { + $storedRefreshToken->recordAudit('invalid_secret', [], null, $request); + $storedRefreshToken->revoke('invalid_secret', null, $request, [ + 'client_id' => $request->client_id, + ]); + return $this->errorResponse('Invalid refresh token', 400); } @@ -288,8 +303,6 @@ class OAuthController extends Controller $currentIp = (string) ($request->ip() ?? ''); if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) { - $storedRefreshToken->update(['revoked_at' => now()]); - Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [ 'client_id' => $request->client_id, 'refresh_token_id' => $storedRefreshToken->id, @@ -297,6 +310,11 @@ class OAuthController extends Controller 'current_ip' => $currentIp, ]); + $storedRefreshToken->revoke('ip_mismatch', null, $request, [ + 'stored_ip' => $storedIp, + 'current_ip' => $currentIp, + ]); + return $this->errorResponse('Refresh token cannot be used from this IP address', 403); } @@ -313,15 +331,36 @@ class OAuthController extends Controller 'tenant_id' => $storedRefreshToken->tenant_id, ]); + $storedRefreshToken->revoke('tenant_missing', null, $request, [ + 'missing_tenant_id' => $storedRefreshToken->tenant_id, + ]); + return $this->errorResponse('Tenant not found', 404); } $scopes = $this->parseScopes($storedRefreshToken->scope); - $storedRefreshToken->update(['revoked_at' => now()]); + $storedRefreshToken->forceFill([ + 'last_used_at' => now(), + ])->save(); + + $storedRefreshToken->recordAudit('refreshed', [ + 'client_id' => $request->client_id, + ], null, $request); $tokenResponse = $this->issueTokenPair($tenant, $client, $scopes, $request); + $newComposite = $tokenResponse['refresh_token'] ?? null; + $newRefreshTokenId = null; + + if ($newComposite && str_contains($newComposite, '|')) { + [$newRefreshTokenId] = explode('|', $newComposite, 2); + } + + $storedRefreshToken->revoke('rotated', null, $request, [ + 'replaced_by' => $newRefreshTokenId, + ]); + return response()->json($tokenResponse); } @@ -370,18 +409,45 @@ class OAuthController extends Controller $composite = $refreshTokenId.'|'.$secret; $expiresAt = now()->addDays(self::REFRESH_TOKEN_TTL_DAYS); - RefreshToken::create([ + /** @var RefreshToken $refreshToken */ + $refreshToken = RefreshToken::create([ 'id' => $refreshTokenId, 'tenant_id' => $tenant->id, 'client_id' => $client->client_id, 'token' => Hash::make($secret), 'access_token' => $accessTokenJti, 'expires_at' => $expiresAt, + 'last_used_at' => now(), 'scope' => implode(' ', $scopes), 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), ]); + $refreshToken->recordAudit('issued', [ + 'scopes' => $scopes, + ], null, $request); + + $maxActive = (int) config('oauth.refresh_tokens.max_active_per_tenant', 5); + + if ($maxActive > 0) { + $activeTokens = RefreshToken::query() + ->forTenant((string) $tenant->id) + ->active() + ->orderByDesc('created_at') + ->get(); + + if ($activeTokens->count() > $maxActive) { + $activeTokens + ->slice($maxActive) + ->each(function (RefreshToken $token) use ($request, $maxActive, $refreshToken): void { + $token->revoke('max_active_limit', null, $request, [ + 'threshold' => $maxActive, + 'new_token' => $refreshToken->id, + ]); + }); + } + } + return $composite; } diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php index 7334ac1..cf92488 100644 --- a/app/Http/Controllers/PayPalWebhookController.php +++ b/app/Http/Controllers/PayPalWebhookController.php @@ -13,11 +13,14 @@ use App\Models\Package; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; use App\Services\PayPal\PaypalClientFactory; +use App\Services\Checkout\CheckoutWebhookService; class PayPalWebhookController extends Controller { - public function __construct(private PaypalClientFactory $clientFactory) - { + public function __construct( + private PaypalClientFactory $clientFactory, + private CheckoutWebhookService $checkoutWebhooks, + ) { } public function verify(Request $request): JsonResponse @@ -59,6 +62,10 @@ class PayPalWebhookController extends Controller Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']); + if ($this->checkoutWebhooks->handlePayPalEvent($event)) { + return; + } + switch ($eventType) { case 'CHECKOUT.ORDER.APPROVED': // Handle order approval if needed diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php new file mode 100644 index 0000000..e52ca0d --- /dev/null +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -0,0 +1,154 @@ +attributes->set('csp_script_nonce', $scriptNonce); + $request->attributes->set('csp_style_nonce', $styleNonce); + + View::share('cspNonce', $scriptNonce); + View::share('cspStyleNonce', $styleNonce); + + Vite::useCspNonce($scriptNonce); + + $response = $next($request); + + if (app()->environment('local') || config('app.debug')) { + return $response; + } + + if ($response->headers->has('Content-Security-Policy')) { + return $response; + } + + $matomoOrigin = $this->normaliseOrigin(config('services.matomo.url')); + $scriptSources = [ + "'self'", + "'nonce-{$scriptNonce}'", + 'https://js.stripe.com', + 'https://js.stripe.network', + ]; + + $styleSources = [ + "'self'", + "'unsafe-inline'", + 'https:', + ]; + + $connectSources = [ + "'self'", + 'https://api.stripe.com', + 'https://api.stripe.network', + ]; + + $frameSources = [ + "'self'", + 'https://js.stripe.com', + ]; + + $imgSources = [ + "'self'", + 'data:', + 'blob:', + 'https:', + ]; + + $fontSources = [ + "'self'", + 'data:', + 'https:', + ]; + + $mediaSources = [ + "'self'", + 'data:', + 'blob:', + 'https:', + ]; + + if ($matomoOrigin) { + $scriptSources[] = $matomoOrigin; + $connectSources[] = $matomoOrigin; + $imgSources[] = $matomoOrigin; + } + + if (app()->environment(['local', 'development']) || config('app.debug')) { + $devHosts = [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'https://localhost:5173', + 'https://127.0.0.1:5173', + ]; + $wsHosts = [ + 'ws://localhost:5173', + 'ws://127.0.0.1:5173', + 'wss://localhost:5173', + 'wss://127.0.0.1:5173', + ]; + + $scriptSources = array_merge($scriptSources, $devHosts, ["'unsafe-inline'", "'unsafe-eval'"]); + $styleSources = array_merge($styleSources, $devHosts, ["'unsafe-inline'"]); + $connectSources = array_merge($connectSources, $devHosts, $wsHosts); + $fontSources = array_merge($fontSources, $devHosts); + $mediaSources = array_merge($mediaSources, $devHosts); + } + + $styleSources[] = 'data:'; + $connectSources[] = 'https:'; + $fontSources[] = 'https:'; + + $directives = [ + 'default-src' => ["'self'"], + 'script-src' => array_unique($scriptSources), + 'style-src' => array_unique($styleSources), + 'img-src' => array_unique($imgSources), + 'font-src' => array_unique($fontSources), + 'connect-src' => array_unique($connectSources), + 'media-src' => array_unique($mediaSources), + 'frame-src' => array_unique($frameSources), + 'form-action' => ["'self'"], + 'base-uri' => ["'self'"], + 'object-src' => ["'none'"], + ]; + + $csp = collect($directives) + ->map(fn ($values, $directive) => $directive.' '.implode(' ', array_filter($values))) + ->implode('; '); + + $response->headers->set('Content-Security-Policy', $csp); + + return $response; + } + + private function normaliseOrigin(?string $url): ?string + { + if (! $url) { + return null; + } + + $parsed = parse_url($url); + if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) { + return null; + } + + $origin = strtolower($parsed['scheme'].'://'.$parsed['host']); + + if (isset($parsed['port'])) { + $origin .= ':'.$parsed['port']; + } + + return $origin; + } +} diff --git a/app/Http/Middleware/StripeCSP.php b/app/Http/Middleware/StripeCSP.php deleted file mode 100644 index 1b23cb1..0000000 --- a/app/Http/Middleware/StripeCSP.php +++ /dev/null @@ -1,27 +0,0 @@ -headers->set('Content-Security-Policy', $csp); - - return $response; - } -} \ No newline at end of file diff --git a/app/Http/Resources/Tenant/EventJoinTokenResource.php b/app/Http/Resources/Tenant/EventJoinTokenResource.php index 3318eee..293dc50 100644 --- a/app/Http/Resources/Tenant/EventJoinTokenResource.php +++ b/app/Http/Resources/Tenant/EventJoinTokenResource.php @@ -36,11 +36,14 @@ class EventJoinTokenResource extends JsonResource ]) : null; + $plainToken = $this->resource->plain_token ?? $this->token; + return [ 'id' => $this->id, 'label' => $this->label, - 'token' => $this->token, - 'url' => url('/e/'.$this->token), + 'token' => $plainToken, + 'token_preview' => $this->token_preview, + 'url' => $plainToken ? url('/e/'.$plainToken) : null, 'usage_limit' => $this->usage_limit, 'usage_count' => $this->usage_count, 'expires_at' => optional($this->expires_at)->toIso8601String(), diff --git a/app/Jobs/ProcessPhotoSecurityScan.php b/app/Jobs/ProcessPhotoSecurityScan.php new file mode 100644 index 0000000..9dd13c2 --- /dev/null +++ b/app/Jobs/ProcessPhotoSecurityScan.php @@ -0,0 +1,85 @@ +queue = config('security.queue.name', 'default'); + } + + public function handle(PhotoSecurityScanner $scanner): void + { + $photo = Photo::with('mediaAsset')->find($this->photoId); + + if (! $photo) { + Log::warning('[PhotoSecurity] Skipping missing photo', ['photo_id' => $this->photoId]); + return; + } + + $asset = $photo->mediaAsset ?? EventMediaAsset::where('photo_id', $photo->id)->where('variant', 'original')->first(); + + if (! $asset) { + Log::warning('[PhotoSecurity] No media asset available for scan', [ + 'photo_id' => $photo->id, + ]); + + $photo->forceFill([ + 'security_scan_status' => 'error', + 'security_scan_message' => 'Media asset not available for scanning.', + 'security_scanned_at' => now(), + ])->save(); + + return; + } + + $disk = $asset->disk ?? config('filesystems.default', 'public'); + $path = $asset->path ?? $photo->file_path; + + $scanResult = $scanner->scan($disk, $path); + $status = $scanResult['status'] ?? 'error'; + $message = $scanResult['message'] ?? null; + + $metadata = [ + 'scan' => $scanResult, + ]; + + if ($status === 'clean' && config('security.exif.strip', true)) { + $stripResult = $scanner->stripExif($disk, $path); + $metadata['exif'] = $stripResult; + } + + $existingMeta = $photo->security_meta ?? []; + + $photo->forceFill([ + 'security_scan_status' => $status, + 'security_scan_message' => $message, + 'security_scanned_at' => now(), + 'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata), + ])->save(); + + if ($status === 'infected') { + Log::alert('[PhotoSecurity] Infected photo detected', [ + 'photo_id' => $photo->id, + 'event_id' => $photo->event_id, + 'message' => $message, + ]); + } + } +} diff --git a/app/Mail/AbandonedCheckout.php b/app/Mail/AbandonedCheckout.php index a75e8a3..8243515 100644 --- a/app/Mail/AbandonedCheckout.php +++ b/app/Mail/AbandonedCheckout.php @@ -17,7 +17,7 @@ class AbandonedCheckout extends Mailable public function __construct( public User $user, public Package $package, - public string $timing, // '1h', '24h', '1w' + public string $timing, public string $resumeUrl ) { // @@ -25,10 +25,9 @@ class AbandonedCheckout extends Mailable public function envelope(): Envelope { - $subjectKey = 'emails.abandoned_checkout.subject_' . $this->timing; return new Envelope( subject: __('emails.abandoned_checkout.subject_' . $this->timing, [ - 'package' => $this->package->name + 'package' => $this->localizedPackageName(), ]), ); } @@ -40,6 +39,7 @@ class AbandonedCheckout extends Mailable with: [ 'user' => $this->user, 'package' => $this->package, + 'packageName' => $this->localizedPackageName(), 'timing' => $this->timing, 'resumeUrl' => $this->resumeUrl, ], @@ -50,4 +50,11 @@ class AbandonedCheckout extends Mailable { return []; } -} \ No newline at end of file + + private function localizedPackageName(): string + { + $locale = $this->locale ?? app()->getLocale(); + + return $this->package->getNameForLocale($locale); + } +} diff --git a/app/Mail/ContactConfirmation.php b/app/Mail/ContactConfirmation.php index 8802b17..ac5831f 100644 --- a/app/Mail/ContactConfirmation.php +++ b/app/Mail/ContactConfirmation.php @@ -20,7 +20,7 @@ class ContactConfirmation extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Vielen Dank fuer Ihre Nachricht bei Fotospiel', + subject: __('emails.contact_confirmation.subject', ['name' => $this->name]), ); } diff --git a/app/Mail/PurchaseConfirmation.php b/app/Mail/PurchaseConfirmation.php index 237f20d..ca4445c 100644 --- a/app/Mail/PurchaseConfirmation.php +++ b/app/Mail/PurchaseConfirmation.php @@ -21,7 +21,7 @@ class PurchaseConfirmation extends Mailable public function envelope(): Envelope { return new Envelope( - subject: __('emails.purchase.subject', ['package' => $this->purchase->package->name]), + subject: __('emails.purchase.subject', ['package' => $this->localizedPackageName()]), ); } @@ -33,6 +33,7 @@ class PurchaseConfirmation extends Mailable 'purchase' => $this->purchase, 'user' => $this->purchase->tenant->user, 'package' => $this->purchase->package, + 'packageName' => $this->localizedPackageName(), ], ); } @@ -41,4 +42,11 @@ class PurchaseConfirmation extends Mailable { return []; } -} \ No newline at end of file + + private function localizedPackageName(): string + { + $locale = $this->locale ?? app()->getLocale(); + + return optional($this->purchase->package)->getNameForLocale($locale) ?? ''; + } +} diff --git a/app/Models/EventJoinToken.php b/app/Models/EventJoinToken.php index a3029f7..518edd9 100644 --- a/app/Models/EventJoinToken.php +++ b/app/Models/EventJoinToken.php @@ -1,9 +1,13 @@ 'integer', ]; + protected $hidden = [ + 'token_encrypted', + 'token_hash', + ]; + + protected $appends = [ + 'token_preview', + ]; + public function event(): BelongsTo { return $this->belongsTo(Event::class); @@ -39,6 +54,11 @@ class EventJoinToken extends Model return $this->belongsTo(User::class, 'created_by'); } + public function analytics(): HasMany + { + return $this->hasMany(EventJoinTokenEvent::class); + } + public function isActive(): bool { if ($this->revoked_at !== null) { @@ -55,4 +75,64 @@ class EventJoinToken extends Model return true; } + + public function getTokenAttribute(?string $value): ?string + { + $encrypted = $this->attributes['token_encrypted'] ?? null; + + if (! empty($encrypted)) { + try { + return Crypt::decryptString($encrypted); + } catch (\Throwable $e) { + try { + return Crypt::decrypt($encrypted); + } catch (\Throwable $e) { + // Fall back to stored hash if both decrypt strategies fail. + } + } + } + + return $value; + } + + public function setTokenAttribute(?string $value): void + { + if ($value === null) { + $this->attributes['token'] = null; + $this->attributes['token_hash'] = null; + $this->attributes['token_encrypted'] = null; + $this->attributes['token_preview'] = null; + + return; + } + + $hash = hash('sha256', $value); + + $this->attributes['token'] = $hash; + $this->attributes['token_hash'] = $hash; + $this->attributes['token_encrypted'] = Crypt::encryptString($value); + $this->attributes['token_preview'] = $this->buildPreview($value); + } + + public function getTokenPreviewAttribute(?string $value): ?string + { + if ($value) { + return $value; + } + + $token = $this->token; + + return $token ? $this->buildPreview($token) : null; + } + + private function buildPreview(string $token): string + { + $length = strlen($token); + + if ($length <= 10) { + return $token; + } + + return substr($token, 0, 6).'
'.substr($token, -4); + } } diff --git a/app/Models/EventJoinTokenEvent.php b/app/Models/EventJoinTokenEvent.php new file mode 100644 index 0000000..02ab728 --- /dev/null +++ b/app/Models/EventJoinTokenEvent.php @@ -0,0 +1,50 @@ + 'array', + 'occurred_at' => 'datetime', + ]; + + public function joinToken(): BelongsTo + { + return $this->belongsTo(EventJoinToken::class, 'event_join_token_id'); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} + diff --git a/app/Models/Package.php b/app/Models/Package.php index a43fcdc..19c3c8e 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -99,6 +99,24 @@ class Package extends Model return $this->type === 'reseller'; } + public function getNameForLocale(?string $locale = null): string + { + $locale = $locale ?: app()->getLocale(); + $translations = $this->name_translations ?? []; + + if (!empty($translations[$locale])) { + return $translations[$locale]; + } + + foreach (['en', 'de'] as $fallback) { + if ($locale !== $fallback && !empty($translations[$fallback])) { + return $translations[$fallback]; + } + } + + return $this->name ?? ''; + } + public function getLimitsAttribute(): array { return [ diff --git a/app/Models/Photo.php b/app/Models/Photo.php index f5abc4b..3159fa2 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -20,6 +20,12 @@ class Photo extends Model protected $casts = [ 'is_featured' => 'boolean', 'metadata' => 'array', + 'security_meta' => 'array', + 'security_scanned_at' => 'datetime', + ]; + + protected $attributes = [ + 'security_scan_status' => 'pending', ]; public function mediaAsset(): BelongsTo diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php index 2cd05b4..d8403b6 100644 --- a/app/Models/RefreshToken.php +++ b/app/Models/RefreshToken.php @@ -4,6 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Http\Request; class RefreshToken extends Model { @@ -12,9 +14,9 @@ class RefreshToken extends Model public $timestamps = false; protected $table = 'refresh_tokens'; - + protected $guarded = []; - + protected $fillable = [ 'id', 'tenant_id', @@ -22,40 +24,97 @@ class RefreshToken extends Model 'token', 'access_token', 'expires_at', + 'last_used_at', 'scope', 'ip_address', 'user_agent', 'revoked_at', + 'revoked_reason', ]; - + protected $casts = [ 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', 'revoked_at' => 'datetime', 'created_at' => 'datetime', ]; - + public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } - - public function revoke(): bool + + public function audits(): HasMany { - return $this->update(['revoked_at' => now()]); + return $this->hasMany(RefreshTokenAudit::class); } - + + public function revoke(?string $reason = null, ?int $performedBy = null, ?Request $request = null, array $context = []): bool + { + $result = $this->update([ + 'revoked_at' => now(), + 'revoked_reason' => $reason, + ]); + + $event = match ($reason) { + 'rotated' => 'rotated', + 'ip_mismatch' => 'ip_mismatch', + 'expired' => 'expired', + 'invalid_secret' => 'invalid_secret', + 'tenant_missing' => 'tenant_missing', + 'max_active_limit' => 'max_active_limit', + default => 'revoked', + }; + + $this->recordAudit( + $event, + array_merge([ + 'reason' => $reason, + ], $context), + $performedBy, + $request + ); + + return $result; + } + public function isActive(): bool { - return $this->revoked_at === null && $this->expires_at > now(); + if ($this->revoked_at !== null) { + return false; + } + + return $this->expires_at === null || $this->expires_at->isFuture(); } - + public function scopeActive($query) { - return $query->whereNull('revoked_at')->where('expires_at', '>', now()); + return $query + ->whereNull('revoked_at') + ->where(function ($inner) { + $inner->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); } - + public function scopeForTenant($query, string $tenantId) { return $query->where('tenant_id', $tenantId); } + + public function recordAudit(string $event, array $context = [], ?int $performedBy = null, ?Request $request = null): void + { + $request ??= request(); + + $this->audits()->create([ + 'tenant_id' => $this->tenant_id, + 'client_id' => $this->client_id, + 'event' => $event, + 'context' => $context ?: null, + 'ip_address' => $request?->ip(), + 'user_agent' => $request?->userAgent(), + 'performed_by' => $performedBy, + 'created_at' => now(), + ]); + } } diff --git a/app/Models/RefreshTokenAudit.php b/app/Models/RefreshTokenAudit.php new file mode 100644 index 0000000..1c31768 --- /dev/null +++ b/app/Models/RefreshTokenAudit.php @@ -0,0 +1,46 @@ + 'array', + 'created_at' => 'datetime', + ]; + + public function refreshToken(): BelongsTo + { + return $this->belongsTo(RefreshToken::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function performedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'performed_by'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8b34c22..f5a2538 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Services\Checkout\CheckoutSessionService; use App\Notifications\UploadPipelineFailed; use App\Services\Storage\EventStorageManager; use App\Services\Storage\StorageHealthService; +use App\Services\Security\PhotoSecurityScanner; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Queue\Events\JobFailed; @@ -29,6 +30,7 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CheckoutPaymentService::class); $this->app->singleton(EventStorageManager::class); $this->app->singleton(StorageHealthService::class); + $this->app->singleton(PhotoSecurityScanner::class); } /** @@ -73,6 +75,26 @@ class AppServiceProvider extends ServiceProvider ]; }); + Inertia::share('security', static function () { + $request = request(); + + if (! $request) { + return [ + 'csp' => [ + 'scriptNonce' => null, + 'styleNonce' => null, + ], + ]; + } + + return [ + 'csp' => [ + 'scriptNonce' => $request->attributes->get('csp_script_nonce'), + 'styleNonce' => $request->attributes->get('csp_style_nonce'), + ], + ]; + }); + if (config('storage-monitor.queue_failure_alerts')) { Queue::failing(function (JobFailed $event) { $context = [ diff --git a/app/Services/Analytics/JoinTokenAnalyticsRecorder.php b/app/Services/Analytics/JoinTokenAnalyticsRecorder.php new file mode 100644 index 0000000..0fe47d2 --- /dev/null +++ b/app/Services/Analytics/JoinTokenAnalyticsRecorder.php @@ -0,0 +1,112 @@ +buildPayload( + $joinToken, + $eventType, + $request, + $context, + $providedToken, + $httpStatus + )); + } catch (\Throwable $exception) { + // Never block the main request if analytics fails + report($exception); + } + } + + private function buildPayload( + ?EventJoinToken $joinToken, + string $eventType, + Request $request, + array $context, + ?string $providedToken, + ?int $httpStatus + ): array { + $route = $request->route(); + $routeName = $route ? $route->getName() : null; + $deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', '')); + $deviceId = $deviceId !== '' ? substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) : null; + + $tokenHash = null; + $tokenPreview = null; + + if ($joinToken) { + $tokenHash = $joinToken->token_hash ?? null; + $tokenPreview = $joinToken->token_preview ?? null; + } elseif ($providedToken) { + $tokenHash = hash('sha256', $providedToken); + $tokenPreview = $this->buildPreview($providedToken); + } + + $eventId = $joinToken?->event_id; + $tenantId = null; + + if ($joinToken && $joinToken->relationLoaded('event')) { + $tenantId = $joinToken->event?->tenant_id; + } elseif ($joinToken) { + $tenantId = $joinToken->event()->value('tenant_id'); + } + + return [ + 'event_join_token_id' => $joinToken?->getKey(), + 'event_id' => $eventId, + 'tenant_id' => $tenantId, + 'token_hash' => $tokenHash, + 'token_preview' => $tokenPreview, + 'event_type' => $eventType, + 'route' => $routeName, + 'http_method' => $request->getMethod(), + 'http_status' => $httpStatus, + 'device_id' => $deviceId, + 'ip_address' => $request->ip(), + 'user_agent' => $this->shortenUserAgent($request->userAgent()), + 'context' => $context ?: null, + 'occurred_at' => now(), + ]; + } + + private function shortenUserAgent(?string $userAgent): ?string + { + if ($userAgent === null) { + return null; + } + + if (Str::length($userAgent) > 1024) { + return Str::substr($userAgent, 0, 1024); + } + + return $userAgent; + } + + private function buildPreview(string $token): string + { + $token = trim($token); + $length = Str::length($token); + + if ($length <= 10) { + return $token; + } + + return Str::substr($token, 0, 6).'
'.Str::substr($token, -4); + } +} + diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 13b6881..1ae28ae 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -84,10 +84,16 @@ class CheckoutAssignmentService } if ($user) { - Mail::to($user)->queue(new Welcome($user)); + $mailLocale = $user->preferred_locale ?? app()->getLocale(); + + Mail::to($user) + ->locale($mailLocale) + ->queue(new Welcome($user)); if ($purchase->wasRecentlyCreated) { - Mail::to($user)->queue(new PurchaseConfirmation($purchase)); + Mail::to($user) + ->locale($mailLocale) + ->queue(new PurchaseConfirmation($purchase)); } AbandonedCheckout::query() diff --git a/app/Services/Checkout/CheckoutWebhookService.php b/app/Services/Checkout/CheckoutWebhookService.php new file mode 100644 index 0000000..2819fda --- /dev/null +++ b/app/Services/Checkout/CheckoutWebhookService.php @@ -0,0 +1,279 @@ +locateStripeSession($intent); + + if (! $session) { + return false; + } + + $lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30); + + if (! $lock->get()) { + Log::info('[CheckoutWebhook] Stripe intent lock busy', [ + 'intent_id' => $intentId, + 'session_id' => $session->id, + ]); + + return true; + } + + try { + $session->forceFill([ + 'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId, + 'provider' => CheckoutSession::PROVIDER_STRIPE, + ])->save(); + + $metadata = [ + 'stripe_last_event' => $eventType, + 'stripe_last_event_id' => $event['id'] ?? null, + 'stripe_intent_status' => $intent['status'] ?? null, + 'stripe_last_update_at' => now()->toIso8601String(), + ]; + + $this->mergeProviderMetadata($session, $metadata); + + return $this->applyStripeIntent($session, $eventType, $intent); + } finally { + $lock->release(); + } + } + + public function handlePayPalEvent(array $event): bool + { + $eventType = $event['event_type'] ?? null; + $resource = $event['resource'] ?? []; + + if (! $eventType || ! is_array($resource)) { + return false; + } + + $orderId = $resource['order_id'] ?? $resource['id'] ?? null; + + $session = $this->locatePayPalSession($resource, $orderId); + + if (! $session) { + return false; + } + + $lockKey = "checkout:webhook:paypal:".($orderId ?: $session->id); + $lock = Cache::lock($lockKey, 30); + + if (! $lock->get()) { + Log::info('[CheckoutWebhook] PayPal lock busy', [ + 'order_id' => $orderId, + 'session_id' => $session->id, + ]); + + return true; + } + + try { + $session->forceFill([ + 'paypal_order_id' => $orderId ?: $session->paypal_order_id, + 'provider' => CheckoutSession::PROVIDER_PAYPAL, + ])->save(); + + $metadata = [ + 'paypal_last_event' => $eventType, + 'paypal_last_event_id' => $event['id'] ?? null, + 'paypal_last_update_at' => now()->toIso8601String(), + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => $resource['id'] ?? null, + ]; + + $this->mergeProviderMetadata($session, $metadata); + + return $this->applyPayPalEvent($session, $eventType, $resource); + } finally { + $lock->release(); + } + } + + protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool + { + switch ($eventType) { + case 'payment_intent.processing': + case 'payment_intent.amount_capturable_updated': + $this->sessions->markProcessing($session, [ + 'stripe_intent_status' => $intent['status'] ?? null, + ]); + return true; + + case 'payment_intent.requires_action': + $reason = $intent['next_action']['type'] ?? 'requires_action'; + $this->sessions->markRequiresCustomerAction($session, $reason); + return true; + + case 'payment_intent.payment_failed': + $failure = $intent['last_payment_error']['message'] ?? 'payment_failed'; + $this->sessions->markFailed($session, $failure); + return true; + + case 'payment_intent.succeeded': + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markProcessing($session, [ + 'stripe_intent_status' => $intent['status'] ?? null, + ]); + + $this->assignment->finalise($session, [ + 'source' => 'stripe_webhook', + 'stripe_payment_intent_id' => $intent['id'] ?? null, + 'stripe_charge_id' => $this->extractStripeChargeId($intent), + ]); + + $this->sessions->markCompleted($session, now()); + } + + return true; + + default: + return false; + } + } + + protected function applyPayPalEvent(CheckoutSession $session, string $eventType, array $resource): bool + { + switch ($eventType) { + case 'CHECKOUT.ORDER.APPROVED': + $this->sessions->markProcessing($session, [ + 'paypal_order_status' => $resource['status'] ?? null, + ]); + return true; + + case 'PAYMENT.CAPTURE.COMPLETED': + if ($session->status !== CheckoutSession::STATUS_COMPLETED) { + $this->sessions->markProcessing($session, [ + 'paypal_order_status' => $resource['status'] ?? null, + ]); + + $this->assignment->finalise($session, [ + 'source' => 'paypal_webhook', + 'paypal_order_id' => $resource['order_id'] ?? null, + 'paypal_capture_id' => $resource['id'] ?? null, + ]); + + $this->sessions->markCompleted($session, now()); + } + + return true; + + case 'PAYMENT.CAPTURE.DENIED': + $this->sessions->markFailed($session, 'paypal_capture_denied'); + return true; + + default: + return false; + } + } + + protected function mergeProviderMetadata(CheckoutSession $session, array $data): void + { + $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); + $session->save(); + } + + protected function locateStripeSession(array $intent): ?CheckoutSession + { + $intentId = $intent['id'] ?? null; + + if ($intentId) { + $session = CheckoutSession::query() + ->where('stripe_payment_intent_id', $intentId) + ->first(); + + if ($session) { + return $session; + } + } + + $metadata = $intent['metadata'] ?? []; + $sessionId = $metadata['checkout_session_id'] ?? null; + + if ($sessionId) { + return CheckoutSession::find($sessionId); + } + + return null; + } + + protected function locatePayPalSession(array $resource, ?string $orderId): ?CheckoutSession + { + if ($orderId) { + $session = CheckoutSession::query() + ->where('paypal_order_id', $orderId) + ->first(); + + if ($session) { + return $session; + } + } + + $metadata = $this->extractPayPalMetadata($resource); + $sessionId = $metadata['checkout_session_id'] ?? null; + + if ($sessionId) { + return CheckoutSession::find($sessionId); + } + + return null; + } + + protected function extractPayPalMetadata(array $resource): array + { + $customId = $resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? null); + if ($customId) { + $decoded = json_decode($customId, true); + if (is_array($decoded)) { + return $decoded; + } + } + + $meta = Arr::get($resource, 'supplementary_data.related_ids', []); + return is_array($meta) ? $meta : []; + } + + protected function extractStripeChargeId(array $intent): ?string + { + $charges = $intent['charges']['data'] ?? null; + if (is_array($charges) && count($charges) > 0) { + return $charges[0]['id'] ?? null; + } + + return null; + } +} + diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index 27f2ce8..001d64e 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -7,6 +7,7 @@ use App\Models\EventJoinToken; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Str; class EventJoinTokenService @@ -15,10 +16,13 @@ class EventJoinTokenService { return DB::transaction(function () use ($event, $attributes) { $tokenValue = $this->generateUniqueToken(); + $tokenHash = $this->hashToken($tokenValue); $payload = [ 'event_id' => $event->id, - 'token' => $tokenValue, + 'token_hash' => $tokenHash, + 'token_encrypted' => Crypt::encryptString($tokenValue), + 'token_preview' => $this->previewToken($tokenValue), 'label' => Arr::get($attributes, 'label'), 'usage_limit' => Arr::get($attributes, 'usage_limit'), 'metadata' => Arr::get($attributes, 'metadata', []), @@ -34,7 +38,9 @@ class EventJoinTokenService $payload['created_by'] = $createdBy; } - return EventJoinToken::create($payload); + return tap(EventJoinToken::create($payload), function (EventJoinToken $model) use ($tokenValue) { + $model->setAttribute('plain_token', $tokenValue); + }); }); } @@ -60,8 +66,16 @@ class EventJoinTokenService public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken { + $hash = $this->hashToken($token); + return EventJoinToken::query() - ->where('token', $token) + ->where(function ($query) use ($hash, $token) { + $query->where('token_hash', $hash) + ->orWhere(function ($inner) use ($token) { + $inner->whereNull('token_hash') + ->where('token', $token); + }); + }) ->when(! $includeInactive, function ($query) { $query->whereNull('revoked_at') ->where(function ($query) { @@ -85,8 +99,25 @@ class EventJoinTokenService { do { $token = Str::random($length); - } while (EventJoinToken::where('token', $token)->exists()); + $hash = $this->hashToken($token); + } while (EventJoinToken::where('token_hash', $hash)->exists()); return $token; } + + protected function hashToken(string $token): string + { + return hash('sha256', $token); + } + + protected function previewToken(string $token): string + { + $length = strlen($token); + + if ($length <= 10) { + return $token; + } + + return substr($token, 0, 6).'
'.substr($token, -4); + } } diff --git a/app/Services/Security/PhotoSecurityScanner.php b/app/Services/Security/PhotoSecurityScanner.php new file mode 100644 index 0000000..44ab2aa --- /dev/null +++ b/app/Services/Security/PhotoSecurityScanner.php @@ -0,0 +1,195 @@ + 'error', + 'message' => 'Missing path for antivirus scan.', + ]; + } + + if (! config('security.antivirus.enabled', false)) { + return [ + 'status' => 'skipped', + 'message' => 'Antivirus scanning disabled.', + ]; + } + + [$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath); + + if (! $absolutePath) { + return [ + 'status' => 'skipped', + 'message' => $message ?? 'Unable to resolve path for antivirus.', + ]; + } + + $binary = config('security.antivirus.binary', '/usr/bin/clamscan'); + $arguments = config('security.antivirus.arguments', '--no-summary'); + $timeout = config('security.antivirus.timeout', 60); + + $command = $binary.' '.$arguments.' '.escapeshellarg($absolutePath); + + try { + $process = Process::fromShellCommandline($command); + $process->setTimeout($timeout); + $process->run(); + + if ($process->isSuccessful()) { + return [ + 'status' => 'clean', + 'message' => trim($process->getOutput()), + ]; + } + + if ($process->getExitCode() === 1) { + return [ + 'status' => 'infected', + 'message' => trim($process->getOutput() ?: $process->getErrorOutput()), + ]; + } + + return [ + 'status' => 'error', + 'message' => trim($process->getErrorOutput() ?: 'Unknown antivirus error.'), + ]; + } catch (ProcessFailedException $exception) { + return [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]; + } catch (\Throwable $exception) { + Log::warning('[PhotoSecurity] Antivirus scan failed', [ + 'disk' => $disk, + 'path' => $relativePath, + 'error' => $exception->getMessage(), + ]); + + return [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]; + } + } + + public function stripExif(string $disk, ?string $relativePath): array + { + if (! config('security.exif.strip', true)) { + return [ + 'status' => 'skipped', + 'message' => 'EXIF stripping disabled.', + ]; + } + + if (! $relativePath) { + return [ + 'status' => 'error', + 'message' => 'Missing path for EXIF stripping.', + ]; + } + + [$absolutePath, $message] = $this->resolveAbsolutePath($disk, $relativePath); + + if (! $absolutePath) { + return [ + 'status' => 'skipped', + 'message' => $message ?? 'Unable to resolve path for EXIF stripping.', + ]; + } + + $extension = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION)); + if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { + return [ + 'status' => 'skipped', + 'message' => 'Unsupported format for EXIF stripping.', + ]; + } + + try { + $contents = @file_get_contents($absolutePath); + if ($contents === false) { + throw new \RuntimeException('Unable to read file for EXIF stripping.'); + } + + $image = @imagecreatefromstring($contents); + if (! $image) { + return [ + 'status' => 'skipped', + 'message' => 'Unable to decode image for EXIF stripping.', + ]; + } + + $result = match ($extension) { + 'png' => imagepng($image, $absolutePath), + 'webp' => function_exists('imagewebp') ? imagewebp($image, $absolutePath) : false, + default => imagejpeg($image, $absolutePath, 90), + }; + + imagedestroy($image); + + if (! $result) { + return [ + 'status' => 'error', + 'message' => 'Failed to re-encode image without EXIF.', + ]; + } + + return [ + 'status' => 'stripped', + 'message' => 'EXIF metadata removed via re-encode.', + ]; + } catch (\Throwable $exception) { + Log::warning('[PhotoSecurity] EXIF stripping failed', [ + 'disk' => $disk, + 'path' => $relativePath, + 'error' => $exception->getMessage(), + ]); + + return [ + 'status' => 'error', + 'message' => $exception->getMessage(), + ]; + } + } + + /** + * @return array{0: string|null, 1: string|null} + */ + private function resolveAbsolutePath(string $disk, string $relativePath): array + { + try { + $storage = Storage::disk($disk); + + if (! method_exists($storage, 'path')) { + return [null, 'Storage driver does not expose local paths.']; + } + + $absolute = $storage->path($relativePath); + + if (! file_exists($absolute)) { + return [null, 'File not found on disk.']; + } + + return [$absolute, null]; + } catch (\Throwable $exception) { + Log::warning('[PhotoSecurity] Unable to resolve absolute path', [ + 'disk' => $disk, + 'path' => $relativePath, + 'error' => $exception->getMessage(), + ]); + + return [null, $exception->getMessage()]; + } + } +} + diff --git a/bootstrap/app.php b/bootstrap/app.php index ec1740f..f59a817 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -20,6 +20,8 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withCommands([ \App\Console\Commands\OAuthRotateKeysCommand::class, + \App\Console\Commands\OAuthListKeysCommand::class, + \App\Console\Commands\OAuthPruneKeysCommand::class, ]) ->withMiddleware(function (Middleware $middleware) { $middleware->alias([ @@ -37,6 +39,7 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Http\Middleware\SetLocale::class, SetLocaleFromUser::class, HandleAppearance::class, + \App\Http\Middleware\ContentSecurityPolicy::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/join_tokens.php b/config/join_tokens.php new file mode 100644 index 0000000..eb535f2 --- /dev/null +++ b/config/join_tokens.php @@ -0,0 +1,12 @@ + (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10), + 'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5), + + 'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120), + 'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1), + + 'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60), + 'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1), +]; diff --git a/config/oauth.php b/config/oauth.php index 6a538b1..a81cb3b 100644 --- a/config/oauth.php +++ b/config/oauth.php @@ -8,6 +8,7 @@ return [ 'refresh_tokens' => [ 'enforce_ip_binding' => env('OAUTH_REFRESH_ENFORCE_IP', true), 'allow_subnet_match' => env('OAUTH_REFRESH_ALLOW_SUBNET', false), + 'max_active_per_tenant' => env('OAUTH_REFRESH_MAX_ACTIVE', 5), + 'audit_retention_days' => env('OAUTH_REFRESH_AUDIT_RETENTION_DAYS', 90), ], ]; - diff --git a/config/security.php b/config/security.php new file mode 100644 index 0000000..7980a75 --- /dev/null +++ b/config/security.php @@ -0,0 +1,17 @@ + [ + 'enabled' => env('SECURITY_AV_ENABLED', false), + 'binary' => env('SECURITY_AV_BINARY', '/usr/bin/clamscan'), + 'arguments' => env('SECURITY_AV_ARGUMENTS', '--no-summary'), + 'timeout' => (int) env('SECURITY_AV_TIMEOUT', 60), + ], + 'exif' => [ + 'strip' => env('SECURITY_STRIP_EXIF', true), + ], + 'queue' => [ + 'name' => env('SECURITY_SCAN_QUEUE', 'media-security'), + ], +]; + diff --git a/database/migrations/2025_10_22_000100_add_secure_columns_to_event_join_tokens.php b/database/migrations/2025_10_22_000100_add_secure_columns_to_event_join_tokens.php new file mode 100644 index 0000000..6fc2a25 --- /dev/null +++ b/database/migrations/2025_10_22_000100_add_secure_columns_to_event_join_tokens.php @@ -0,0 +1,95 @@ +string('token_hash', 128)->nullable()->after('token'); + $table->index('token_hash', 'event_join_tokens_token_hash_index'); + $addedTokenHashColumn = true; + } + + if (!Schema::hasColumn('event_join_tokens', 'token_encrypted')) { + $table->text('token_encrypted')->nullable()->after('token_hash'); + } + + if (!Schema::hasColumn('event_join_tokens', 'token_preview')) { + $table->string('token_preview', 32)->nullable()->after('token_encrypted'); + } + }); + + DB::table('event_join_tokens') + ->whereNull('token_hash') + ->whereNotNull('token') + ->orderBy('id') + ->chunkById(200, function ($tokens) { + foreach ($tokens as $token) { + $hash = hash('sha256', $token->token); + $preview = $this->previewToken($token->token); + + DB::table('event_join_tokens') + ->where('id', $token->id) + ->update([ + 'token_hash' => $hash, + 'token_encrypted' => Crypt::encryptString($token->token), + 'token_preview' => $preview, + 'token' => $hash, + ]); + } + }); + + if ($addedTokenHashColumn) { + Schema::table('event_join_tokens', function (Blueprint $table) { + $table->unique('token_hash', 'event_join_tokens_token_hash_unique'); + }); + } + } + + public function down(): void + { + Schema::table('event_join_tokens', function (Blueprint $table) { + if (Schema::hasColumn('event_join_tokens', 'token_hash')) { + try { + $table->dropUnique('event_join_tokens_token_hash_unique'); + } catch (\Throwable $e) { + // Unique index might already be absent (e.g. partial rollback). + } + + try { + $table->dropIndex('event_join_tokens_token_hash_index'); + } catch (\Throwable $e) { + // Index might already be absent. + } + } + + $columns = collect(['token_hash', 'token_encrypted', 'token_preview']) + ->filter(fn ($column) => Schema::hasColumn('event_join_tokens', $column)) + ->all(); + + if (!empty($columns)) { + $table->dropColumn($columns); + } + }); + } + + private function previewToken(string $token): string + { + $length = strlen($token); + + if ($length <= 10) { + return $token; + } + + return substr($token, 0, 6).'
'.substr($token, -4); + } +}; diff --git a/database/migrations/2025_10_22_000200_add_security_columns_to_photos.php b/database/migrations/2025_10_22_000200_add_security_columns_to_photos.php new file mode 100644 index 0000000..4206456 --- /dev/null +++ b/database/migrations/2025_10_22_000200_add_security_columns_to_photos.php @@ -0,0 +1,26 @@ +string('security_scan_status')->default('pending')->after('metadata'); + $table->text('security_scan_message')->nullable()->after('security_scan_status'); + $table->timestamp('security_scanned_at')->nullable()->after('security_scan_message'); + $table->json('security_meta')->nullable()->after('security_scanned_at'); + }); + } + + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropColumn(['security_scan_status', 'security_scan_message', 'security_scanned_at', 'security_meta']); + }); + } +}; + diff --git a/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php b/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php new file mode 100644 index 0000000..9dbec79 --- /dev/null +++ b/database/migrations/2025_10_22_000300_create_refresh_token_audits_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('refresh_token_id'); + $table->string('tenant_id')->index(); + $table->string('client_id')->nullable()->index(); + $table->string('event', 64)->index(); + $table->json('context')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('refresh_token_id') + ->references('id') + ->on('refresh_tokens') + ->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('refresh_token_audits'); + } +}; diff --git a/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php b/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php new file mode 100644 index 0000000..fd64f67 --- /dev/null +++ b/database/migrations/2025_10_22_000400_add_last_used_to_refresh_tokens.php @@ -0,0 +1,34 @@ +timestamp('last_used_at')->nullable()->after('expires_at'); + } + + if (! Schema::hasColumn('refresh_tokens', 'revoked_reason')) { + $table->string('revoked_reason', 64)->nullable()->after('revoked_at'); + } + }); + } + + public function down(): void + { + Schema::table('refresh_tokens', function (Blueprint $table) { + if (Schema::hasColumn('refresh_tokens', 'last_used_at')) { + $table->dropColumn('last_used_at'); + } + + if (Schema::hasColumn('refresh_tokens', 'revoked_reason')) { + $table->dropColumn('revoked_reason'); + } + }); + } +}; diff --git a/database/migrations/2025_10_23_000500_create_event_join_token_events_table.php b/database/migrations/2025_10_23_000500_create_event_join_token_events_table.php new file mode 100644 index 0000000..dc1a90e --- /dev/null +++ b/database/migrations/2025_10_23_000500_create_event_join_token_events_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('event_join_token_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('event_id')->nullable()->index(); + $table->unsignedBigInteger('tenant_id')->nullable()->index(); + $table->string('token_hash', 64)->nullable()->index(); + $table->string('token_preview', 32)->nullable(); + $table->string('event_type', 32); + $table->string('route', 100)->nullable()->index(); + $table->string('http_method', 16)->nullable(); + $table->unsignedSmallInteger('http_status')->nullable(); + $table->string('device_id', 64)->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->json('context')->nullable(); + $table->timestamp('occurred_at')->useCurrent(); + $table->timestamps(); + + $table->index(['event_join_token_id', 'occurred_at'], 'event_join_token_events_token_time_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_join_token_events'); + } +}; + diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php index de0c1dc..e43db95 100644 --- a/database/seeders/OAuthClientSeeder.php +++ b/database/seeders/OAuthClientSeeder.php @@ -18,7 +18,7 @@ class OAuthClientSeeder extends Seeder $serviceConfig = config('services.oauth.tenant_admin', []); $clientId = $serviceConfig['id'] ?? 'tenant-admin-app'; - $tenantId = Tenant::where('slug', 'demo')->value('id') + $tenantId = Tenant::where('slug', 'demo-tenant')->value('id') ?? Tenant::query()->orderBy('id')->value('id'); $redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []); diff --git a/docs/deployment/join-token-analytics.md b/docs/deployment/join-token-analytics.md new file mode 100644 index 0000000..58d4a11 --- /dev/null +++ b/docs/deployment/join-token-analytics.md @@ -0,0 +1,28 @@ +# Join Token Analytics & Alerting (SEC-GT-02) + +## Data Sources +- Table `event_join_token_events` captures successes, failures, rate-limit hits, and uploads per join token. +- Each row records route, device id, IP, HTTP status, and context for post-incident drill downs. +- Logged automatically from `EventPublicController` for `/api/v1/events/*` and `/api/v1/gallery/*`. + +- Super Admin: Event resource → “Join Link / QR” modal now summarises total successes/failures, rate-limit hits, 24h volume, and last activity timestamp per token. +- Tenant Admin: identical modal surface so operators can monitor invite health. + +## Alert Thresholds (initial) +- **Rate limit spike**: >25 `token_rate_limited` entries for a token within 10 minutes → flag in monitoring (Grafana/Prometheus TODO). +- **Failure ratio**: failure_count / success_count > 0.5 over rolling hour triggers warning for support follow-up. +- **Inactivity**: tokens without access for >30 days should be reviewed; scheduled report TBD. + +Rate-limiter knobs (see `.env.example`): +- `JOIN_TOKEN_FAILURE_LIMIT` / `JOIN_TOKEN_FAILURE_DECAY` — repeated invalid attempts before temporary block (default 10 tries per 5 min). +- `JOIN_TOKEN_ACCESS_LIMIT` / `JOIN_TOKEN_ACCESS_DECAY` — successful request ceiling per token/IP (default 120 req per minute). +- `JOIN_TOKEN_DOWNLOAD_LIMIT` / `JOIN_TOKEN_DOWNLOAD_DECAY` — download ceiling per token/IP (default 60 downloads per minute). + +## Follow-up Tasks +1. Wire aggregated metrics into Grafana once metrics pipeline is ready (synthetic monitors pending SEC-GT-03). +2. Implement scheduled command to email tenants a weekly digest of token activity and stale tokens. +3. Consider anonymising device identifiers before long-term retention (privacy review). + +## Runbook Notes +- Analytics table may grow quickly for high-traffic events; plan nightly prune job (keep 90 days). +- Use `php artisan tinker` to inspect token activity: `EventJoinTokenEvent::where('event_join_token_id', $id)->latest()->limit(20)->get()`. diff --git a/docs/deployment/oauth-key-rotation.md b/docs/deployment/oauth-key-rotation.md new file mode 100644 index 0000000..ad04ff2 --- /dev/null +++ b/docs/deployment/oauth-key-rotation.md @@ -0,0 +1,58 @@ +# OAuth JWT Key Rotation Playbook (Dual-Key) + +## Purpose +Ensure marketing/tenant OAuth tokens remain valid during RSA key rotations by keeping the previous signing key available until all legacy tokens expire. + +## Prerequisites +- Environment variable `OAUTH_KEY_STORE` points to a shared filesystem (default `storage/app/oauth-keys`). +- `OAUTH_JWT_KID` set to the current signing key id. +- Application deploy tooling able to propagate `.env` changes promptly. +- Operations access to run artisan commands in the target environment. + +## Rotation Workflow + +1. **Review existing keys** + ```bash + php artisan oauth:list-keys + ``` + Confirm the `current` entry matches `OAUTH_JWT_KID`, note any legacy KIDs that should remain trusted until rotation completes. + +2. **Generate new key pair** + ```bash + php artisan oauth:rotate-keys --kid=fotospiel-jwt-$(date +%Y%m%d%H%M) + ``` + - The command now *copies* the existing key into the `/archive` folder but leaves it in-place for token verification. + - After the command, run `php artisan oauth:list-keys` again to verify both the old and new KIDs exist. + +3. **Update environment configuration** + - Set `OAUTH_JWT_KID` to the newly generated value. + - Deploy the updated config (restart queue workers/web instances if they cache config). + +4. **Smoke test issuance** + - Request a fresh OAuth token (PKCE flow) and inspect the JWT header — `kid` must match the new value. + - Use an existing token issued **before** the rotation to hit a tenant API route; it should continue to verify because the old key remains present. + +5. **Monitor** + - Watch application logs for `Invalid token` / `JWT public key not found` errors over the next 24h. + - Investigate any anomalies before pruning. + +## Pruning Legacy Keys +After the longest access-token + refresh-token lifetime (default: 30 days for refresh), prune the legacy signing directory. + +```bash +php artisan oauth:prune-keys --days=45 --force +``` + +- Use `--dry-run` first to see which directories would be removed. +- The prune command never deletes the `current` KID. +- Archived copies remain under `storage/app/oauth-keys/archive/...` for forensics. + +## Runbook Summary +| Step | Command | Outcome | +| --- | --- | --- | +| Inspect | `php artisan oauth:list-keys` | Inventory current + legacy keys | +| Rotate | `php artisan oauth:rotate-keys --kid=...` | Creates new key while keeping legacy key active | +| Verify | Issue new token + test old token | Ensures dual-key window works | +| Prune | `php artisan oauth:prune-keys --days=45` | Removes legacy key once safe | + +Document completion of `SEC-IO-01` in `docs/todo/security-hardening-epic.md` when the rotation runbook has been rehearsed in staging. diff --git a/docs/deployment/public-api-incident-playbook.md b/docs/deployment/public-api-incident-playbook.md new file mode 100644 index 0000000..bfb0163 --- /dev/null +++ b/docs/deployment/public-api-incident-playbook.md @@ -0,0 +1,106 @@ +# Public API Incident Response Playbook (SEC-API-02) + +Scope: Guest-facing API endpoints that rely on join tokens and power the guest PWA plus the public gallery. This includes: + +- `/api/v1/events/{token}/*` (stats, tasks, uploads, photos) +- `/api/v1/gallery/{token}/*` +- Signed download/asset routes generated via `EventPublicController` + +The playbook focuses on abuse, availability loss, and leaked content. + +--- + +## 1. Detection & Alerting + +| Signal | Where to Watch | Notes | +| --- | --- | --- | +| 4xx/5xx spikes | Application logs (`storage/logs/laravel.log`), centralized logging | Look for repeated `Join token access denied` / `token_rate_limited` or unexpected 5xx. | +| Rate-limit triggers | Laravel log lines emitted from `EventPublicController::handleTokenFailure` | Contains IP + truncated token preview. | +| CDN/WAF alerts | Reverse proxy (if enabled) | Ensure 429/403 anomalies are forwarded to incident channel. | +| Synthetic monitors | Planned via `SEC-API-03` | Placeholder until monitors exist. | + +Manual check commands: + +```bash +php artisan log:tail --lines=200 | grep "Join token" +php artisan log:tail --lines=200 | grep "gallery" +``` + +## 2. Severity Classification + +| Level | Criteria | Examples | +| --- | --- | --- | +| SEV-1 | Wide outage (>50% error rate), confirmed data leak or malicious mass-download | Gallery downloads serving wrong event, join-token table compromised. | +| SEV-2 | Localised outage (single tenant/event) or targeted brute force attempting to enumerate tokens | Single event returning 500, repeated `invalid_token` from single IP range. | +| SEV-3 | Minor functional regression or cosmetic issue | Rate limit misconfiguration causing occasional 429 for legitimate users. | + +Escalate SEV-1/2 immediately to on-call via Slack `#incident-response` and open PagerDuty incident (if configured). + +## 3. Immediate Response Checklist + +1. **Confirm availability** + - `curl -I https://app.test/api/v1/gallery/{known_good_token}` + - Use tenant-provided test token to validate `/events/{token}` flow. +2. **Snapshot logs** + - Export last 15 minutes from log aggregator or `storage/logs`. Attach to incident ticket. +3. **Assess scope** + - Identify affected tenant/event IDs via log context. + - Note IP addresses triggering rate limits. +4. **Decide mitigation** + - Brute force? → throttle/bock offending IPs. + - Compromised token? → revoke token via Filament or `php artisan tenant:join-tokens:revoke {id}` (once command exists). + - Endpoint regression? → begin rolling fix or feature flag toggle. + +## 4. Mitigation Tactics + +### 4.1 Abuse / Brute force +- Increase rate-limiter strictness temporarily by editing `config/limiting.php` (if available) or applying runtime block in the load balancer. +- Use fail2ban/WAF rules to block offending IPs. For quick local action: + ```bash + sudo ufw deny from + ``` +- Consider temporarily disabling gallery download by setting `PUBLIC_GALLERY_ENABLED=false` (feature flag planned) and clearing cache. + +### 4.2 Token Compromise +- Revoke specific token via Filament “Join Tokens” modal (Event → Join Tokens → revoke). +- Notify tenant with replacement token instructions. +- Audit join-token logs for additional suspicious use and consider rotating all tokens for the event. + +### 4.3 Internal Failure (500s) +- Tail logs for stack traces. +- If due to downstream storage, fail closed: return 503 with maintenance banner while running `php artisan storage:diagnostics`. +- Roll back recent deployment or disable new feature flag if traced to release. + +## 5. Communication + +| Audience | Channel | Cadence | +| --- | --- | --- | +| Internal on-call | Slack `#incident-response`, PagerDuty | Initial alert, hourly updates. | +| Customer Support | Slack `#support` with summary | Once per significant change (mitigation applied, issue resolved). | +| Tenants | Email template “Public gallery disruption” (see `resources/lang/*/emails.php`) | Only for SEV-1 or impactful SEV-2 after mitigation. | + +Document timeline, impact, and mitigation in the incident ticket. + +## 6. Verification & Recovery + +After applying mitigation: + +1. Re-run test requests for affected endpoints. +2. Validate join-token creation/revocation via Filament. +3. Confirm error rates return to baseline in monitoring/dashboard. +4. Remove temporary firewall blocks once threat subsides. + +## 7. Post-Incident Actions + +- File RCA within 48 hours including: root cause, detection gaps, follow-up tasks (e.g., enabling synthetic monitors, adding audit fields). +- Update documentation if new procedures are required (`docs/prp/11-public-gallery.md`, `docs/prp/03-api.md`). +- Schedule backlog items for long-term fixes (e.g., better anomaly alerting, token analytics dashboards). + +## 8. References & Tools + +- Log aggregation: `storage/logs/laravel.log` (local), Stackdriver/Splunk (staging/prod). +- Rate limit config: `App\Providers\AppServiceProvider` → `RateLimiter::for('tenant-api')` and `EventPublicController::handleTokenFailure`. +- Token management UI: Filament → Events → Join Tokens. +- Signed URL generation: `app/Http/Controllers/Api/EventPublicController` (for tracing download issues). + +Keep this document alongside the other deployment runbooks and review quarterly. diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index bcec610..61a99e0 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -1,6 +1,32 @@ ### Update 2025-10-21 - Phase 3 credit scope delivered: tenant event creation now honours package allowances *and* credit balances (middleware + ledger logging), RevenueCat webhook signature checks ship with queue/backoff + env config, idempotency covered via unit tests. - Follow-up (separate): evaluate photo upload quota enforcement + SuperAdmin ledger visualisations once package analytics stabilise. + +### Upcoming (Next Weeks — Security Hardening) +- Week 1 + - `SEC-IO-01` dual-key rollout playbook. + - `SEC-GT-01` hashed join tokens migration. + - `SEC-API-01` signed asset URLs. + - `SEC-MS-01` AV/EXIF worker integration. + - `SEC-BILL-01` checkout session linkage. + - `SEC-FE-01` CSP nonce utility. +- Week 2 + - `SEC-IO-02` refresh-token management UI. *(delivered 2025-10-23)* + - `SEC-GT-02` token analytics dashboards. + - `SEC-API-02` incident response playbook. + - `SEC-MS-02` streaming upload refactor. + - `SEC-BILL-02` webhook signature freshness. + - `SEC-FE-02` consent-gated analytics loader. +- Week 3 + - `SEC-IO-03` subnet/device configuration. + - `SEC-GT-03` gallery rate-limit alerts. + - `SEC-API-03` synthetic monitoring. + - `SEC-MS-03` checksum validation alerts. + - `SEC-BILL-03` failed capture notifications. + - `SEC-FE-03` cookie banner localisation refresh. +- Week 4 + - `SEC-MS-04` storage health dashboard widget (Media Services). + # Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt) ## Implementierungsstand (Aktualisiert: 2025-09-15) diff --git a/docs/prp/09-security-compliance.md b/docs/prp/09-security-compliance.md index b8f61ce..30614a0 100644 --- a/docs/prp/09-security-compliance.md +++ b/docs/prp/09-security-compliance.md @@ -10,13 +10,14 @@ ## 2025 Hardening Priorities - **Identity & OAuth** — *Owner: Backend Platform* - Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions. + Track JWT key rotation via `oauth:rotate-keys`, roll out dual-key support (old/new KID overlap), surface refresh-token revocation tooling, and extend IP/device binding rules for long-lived sessions. See `docs/deployment/oauth-key-rotation.md` for the rotation playbook. Filament now offers a refresh-token console with per-device revocation and audit history. - **Guest Join Tokens** — *Owner: Guest Platform* - Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards. + Hash stored join tokens, add anomaly metrics (usage spikes, stale tokens), and tighten gallery/photo rate limits with visibility in storage dashboards. Join-token access is now logged to `event_join_token_events` with summaries surfaced in the Event admin modal. - **Public API Resilience** — *Owner: Core API* - Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides. + Ensure gallery/download endpoints serve signed URLs, expand abuse throttles (token + IP), and document incident response runbooks in ops guides. See `docs/deployment/public-api-incident-playbook.md` for the response checklist. - **Media Pipeline & Storage** — *Owner: Media Services* Introduce antivirus + EXIF scrubbing workers, stream uploads to disk to avoid buffering, and enforce checksum verification during hot→archive transfers with configurable alerts from `StorageHealthService`. + - Queue `media-security` (job: `ProcessPhotoSecurityScan`) performs antivirus + EXIF sanitisation per upload; configure via `config/security.php`. - **Payments & Webhooks** — *Owner: Billing* Align legacy Stripe hooks with checkout sessions, add idempotency locks/signature expiry checks, and plug failed capture notifications into the credit ledger audit trail. - **Frontend & CSP** — *Owner: Marketing Frontend* diff --git a/docs/queue-supervisor/README.md b/docs/queue-supervisor/README.md index f91cfc2..9442bdd 100644 --- a/docs/queue-supervisor/README.md +++ b/docs/queue-supervisor/README.md @@ -45,6 +45,19 @@ services: QUEUE_SLEEP: 5 command: > /var/www/html/docs/queue-supervisor/queue-worker.sh media-storage + + media-security-worker: + image: fotospiel-app + restart: unless-stopped + depends_on: + - redis + environment: + APP_ENV: ${APP_ENV:-production} + QUEUE_CONNECTION: redis + QUEUE_TRIES: 3 + QUEUE_SLEEP: 5 + command: > + /var/www/html/docs/queue-supervisor/queue-worker.sh media-security ``` Scale workers by increasing `deploy.replicas` (Swarm) or adding `scale` counts (Compose v2). @@ -74,6 +87,7 @@ Expose Horizon via your web proxy and protect it with authentication (the app al - `QUEUE_CONNECTION` — should match the driver configured in `.env` (`redis` recommended). - `QUEUE_TRIES`, `QUEUE_SLEEP`, `QUEUE_TIMEOUT`, `QUEUE_MAX_TIME` — optional tuning knobs consumed by `queue-worker.sh`. - `STORAGE_ALERT_EMAIL` — enables upload failure notifications introduced in the new storage pipeline. +- `SECURITY_SCAN_QUEUE` — overrides the queue name for the photo antivirus/EXIF worker (`media-security` by default). - Redis / database credentials must be available in the worker containers exactly like the web container. ### 5. Bootstrapping reminder diff --git a/docs/todo/media-streaming-upload-refactor.md b/docs/todo/media-streaming-upload-refactor.md new file mode 100644 index 0000000..ba9f553 --- /dev/null +++ b/docs/todo/media-streaming-upload-refactor.md @@ -0,0 +1,131 @@ +# SEC-MS-02 — Streaming Upload Refactor (Requirements Draft) + +**Goal** +Replace the current “single POST with multipart FormData” guest upload with a streaming / chunked pipeline that: + +- avoids buffering entire files in PHP memory +- supports larger assets (target 25 MB originals) +- keeps antivirus/EXIF scrubbing and storage accounting intact +- exposes clear retry semantics to the guest PWA + +This document captures the scope for SEC-MS-02 and feeds into implementation tickets. + +--- + +## 1. Current State (Baseline) + +- Upload endpoint: `POST /api/v1/events/{token}/upload` handled by `EventPublicController::upload`. +- Laravel validation enforces `image|max:6144` (≈6 MB). Entire file is received via `Request::file('photo')`. +- Storage flow: `Storage::disk($hotDisk)->putFile(...)` followed by synchronous thumbnail creation and `event_media_assets` bookkeeping. +- Device rate limiting: simple counter (`guest_name` = device id) per event. +- Security: join token validation + IP rate limiting; antivirus/exif cleanup handled asynchronously by `ProcessPhotoSecurityScan` (queued). +- Frontend: guest PWA uses `fetch` + FormData; progress handled by custom XHR queue for UI feedback. + +Pain points: +- Upload size ceiling due to PHP post_max_size + memory usage. +- Slow devices stall the controller request; no streaming/chunk resume. +- Throttling/locks only consider completed uploads; partial data still consumes bandwidth. + +--- + +## 2. Target Architecture Overview + +### 2.1 Session-Based Chunk Upload + +1. **Create session** + - `POST /api/v1/events/{token}/uploads` → returns `upload_id`, `upload_key`, storage target, chunk size. + - Validate join token + device limits *before* accepting session. Record session in new table `event_upload_sessions`. + +2. **Upload chunks** + - `PUT /api/v1/events/{token}/uploads/{upload_id}/chunk` with headers: `Content-Range`, `Content-Length`, `Upload-Key`. + - Chunks written to hot storage *stream* destination (e.g. `storage/app/uploads/{upload_id}/chunk_{index}`) via `StreamedResponse`/`fopen`. + - Track received ranges in session record; enforce sequential or limited parallel chunks. + +3. **Complete upload** + - `POST /api/v1/events/{token}/uploads/{upload_id}/complete` + - Assemble chunks → single file (use stream copy to final path), compute checksum, dispatch queue jobs (AV/EXIF, thumbnail). + - Persist `photos` row + `event_media_assets` references (mirroring current logic). + +4. **Abort** + - `DELETE /api/v1/events/{token}/uploads/{upload_id}` to clean up partial data. + +### 2.2 Storage Strategy + +- Use `EventStorageManager` hot disk but with temporary “staging” directory. +- After successful assembly, move to final `events/{eventId}/photos/{uuid}.ext`. +- For S3 targets, evaluate direct multipart upload to S3 using pre-signed URLs: + - Option A (short-term): stream into local disk, then background job pushes to S3. + - Option B (stretch): delegate chunk upload directly to S3 using `createMultipartUpload`, storing uploadId + partETags. +- Ensure staging cleanup job removes abandoned sessions (cron every hour). + +### 2.3 Metadata & Limits + +- New table `event_upload_sessions` fields: + `id (uuid)`, `event_id`, `join_token_id`, `device_id`, `status (pending|uploading|assembling|failed|completed)`, `total_size`, `received_bytes`, `chunk_size`, `expires_at`, `failure_reason`, timestamps. +- Device/upload limits: enforce daily cap per device via session creation; consider max concurrent sessions per device/token (default 2). +- Maximum file size: 25 MB (configurable via `config/media.php`). Validate at `complete` by comparing expected vs actual bytes. + +### 2.4 Validation & Security + +- Require `Upload-Key` secret per session (stored hashed) to prevent hijacking. +- Join token + device validations reused; log chunk IP + UA for anomaly detection. +- Abort sessions on repeated integrity failures or mismatched `Content-Range`. +- Update rate limiter to consider `PUT` chunk endpoints separately. + +### 2.5 API Responses & Errors + +- Provide consistent JSON: + - `201` create: `{ upload_id, chunk_size, expires_at }` + - chunk success: `204` + - complete: `201 { photo_id, file_path, thumbnail_path }` + - error codes: `upload_limit`, `chunk_out_of_order`, `range_mismatch`, `session_expired`. +- Document in `docs/prp/03-api.md` + update guest SDK. + +### 2.6 Backend Jobs + +- Assembly job (if asynchronous) ensures chunk merge is offloaded for large files; update `ProcessPhotoSecurityScan` to depend on final asset record. +- Add metric counters (Prometheus/Laravel events) for chunk throughput, failed sessions, average complete time. + +--- + +## 3. Frontend Changes (Guest PWA) + +- Replace current FormData POST with streaming uploader: + - Request session, slice file into `chunk_size` (default 1 MB) using `Blob.slice`, upload sequentially with retry/backoff. + - Show granular progress (bytes uploaded / total). + - Support resume: store `upload_id` & received ranges in IndexedDB; on reconnect query session status from new endpoint `GET /api/v1/events/{token}/uploads/{upload_id}`. +- Ensure compatibility fallback: if browser lacks required APIs (e.g. old Safari), fallback to legacy single POST (size-limited) with warning. +- Update service worker/queue to pause/resume chunk uploads when offline. + +--- + +## 4. Integration & Migration Tasks + +1. **Schema**: create `event_upload_sessions` table + indices; optional `event_upload_chunks` if tracking per-part metadata. +2. **Config**: new entries in `config/media.php` for chunk size, staging path, session TTL, max size. +3. **Env**: add `.env` knobs (e.g. `MEDIA_UPLOAD_CHUNK_SIZE=1048576`, `MEDIA_UPLOAD_MAX_SIZE=26214400`). +4. **Cleanup Command**: `php artisan media:prune-upload-sessions` to purge expired sessions & staging files. Hook into cron `/cron/media-prune-sessions.sh`. +5. **Docs**: update PRP (sections 03, 10) and guest PWA README; add troubleshooting guide for chunk upload errors. +6. **Testing**: + - Unit: session creation, chunk validation, assembly with mocked storage. + - Feature: end-to-end upload success + failure (PHPUnit). + - Playwright: simulate chunked upload with network throttling. + - Load: ensure concurrent uploads do not exhaust disk IO. + +--- + +## 5. Open Questions + +- **S3 Multipart vs. Local Assembly**: confirm timeline for direct-to-S3; MVP may prefer local assembly to limit complexity. +- **Encryption**: decide whether staging chunks require at-rest encryption (likely yes if hot disk is shared). +- **Quota Enforcement**: should device/event caps be session-based (limit sessions) or final photo count (existing)? Combine both? +- **Backward Compatibility**: decide when to retire legacy endpoint; temporarily keep `/upload` fallback behind feature flag. + +--- + +## 6. Next Steps + +- Finalise design choices (S3 vs local) with Media Services. +- Break down into implementation tasks (backend API, frontend uploader, cron cleanup, observability). +- Schedule dry run in staging with large sample files (20+ MB) and monitor memory/CPU. +- Update SEC-MS-02 ticket checklist with deliverables above. diff --git a/docs/todo/security-hardening-epic.md b/docs/todo/security-hardening-epic.md index afbd6ab..bf73de8 100644 --- a/docs/todo/security-hardening-epic.md +++ b/docs/todo/security-hardening-epic.md @@ -9,31 +9,56 @@ Raise the baseline security posture across guest APIs, checkout, media storage, - Dual-key rollout for JWT signing with rotation runbook and monitoring. - Refresh-token revocation tooling (per device/IP) and anomaly alerts. - Device fingerprint/subnet allowances documented and configurable. + - **Tickets** + - `SEC-IO-01` — Generate dual-key rollout playbook + automation (Week 1). *(Runbook: `docs/deployment/oauth-key-rotation.md`; commands: `oauth:list-keys`, `oauth:prune-keys`)* + - `SEC-IO-02` — Build refresh-token management UI + audit logs (Week 2). *(Filament console + audit trail added 2025-10-23)* + - `SEC-IO-03` — Implement subnet/device matching configuration & tests (Week 3). 2. **Guest Join Tokens (Guest Platform)** - Store hashed tokens with irreversible lookups; migrate legacy data. - Add per-token usage analytics, alerting on spikes or expiry churn. - - Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards. + - Extend gallery/photo rate limits (token + IP) and surface breach telemetry in storage dashboards. + - **Tickets** + - `SEC-GT-01` — Hash join tokens + data migration script (Week 1). + - `SEC-GT-02` — Implement token analytics + Grafana dashboard (Week 2). *(Logging + Filament summaries delivered 2025-10-23; monitoring dashboard still pending)* + - `SEC-GT-03` — Tighten gallery/photo rate limits + alerting (Week 3). 3. **Public API Resilience (Core API)** - Serve signed asset URLs instead of raw storage paths; expire appropriately. - Document incident response runbooks and playbooks for abuse mitigation. - Add synthetic monitors for `/api/v1/gallery/*` and upload endpoints. + - **Tickets** + - `SEC-API-01` — Signed URL middleware + asset migration (Week 1). + - `SEC-API-02` — Incident response playbook draft + review (Week 2). *(Runbook: `docs/deployment/public-api-incident-playbook.md`, added 2025-10-23)* + - `SEC-API-03` — Synthetic monitoring + alert config (Week 3). 4. **Media Pipeline & Storage (Media Services)** - Integrate antivirus/EXIF scrubbers and streaming upload paths to avoid buffering. - Verify checksum integrity on hot → archive transfers with alert thresholds. - Surface storage target health (capacity, latency) in Super Admin dashboards. + - **Tickets** + - `SEC-MS-01` — AV + EXIF scrubber worker integration (Week 1). *(Job: `ProcessPhotoSecurityScan`, queue: `media-security`)* + - `SEC-MS-02` — Streaming upload refactor + tests (Week 2). *(Requirements draft: `docs/todo/media-streaming-upload-refactor.md`, 2025-10-23)* + - `SEC-MS-03` — Checksum validation + alert thresholds (Week 3). + - `SEC-MS-04` — Storage health widget in Super Admin (Week 4). 5. **Payments & Webhooks (Billing)** - Link Stripe/PayPal webhooks to checkout sessions with idempotency locks. - Add signature freshness validation + retry policies for provider outages. - Pipe failed capture events into credit ledger audits and operator alerts. + - **Tickets** + - `SEC-BILL-01` — Checkout session linkage + idempotency locks (Week 1). + - `SEC-BILL-02` — Signature freshness + retry policy implementation (Week 2). + - `SEC-BILL-03` — Failed capture notifications + ledger hook (Week 3). 6. **Frontend & CSP (Marketing Frontend)** - Replace `unsafe-inline` allowances with nonce/hash policies for Stripe + Matomo. - Gate analytics script injection behind consent with localised disclosures. - Broaden cookie banner layout to surface GDPR/legal copy clearly. + - **Tickets** + - `SEC-FE-01` — CSP nonce/hashing utility + rollout (Week 1). + - `SEC-FE-02` — Consent-gated analytics loader refactor (Week 2). + - `SEC-FE-03` — Cookie banner UX update + localisation (Week 3). ## Deliverables - Updated docs (`docs/prp/09-security-compliance.md`, runbooks) with ownership & SLAs. diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index 25968d3..b297acd 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -6,7 +6,8 @@ "occasions": { "wedding": "Hochzeit", "birthday": "Geburtstag", - "corporate": "Firmenevent" + "corporate": "Firmenevent", + "label": "AnlĂ€sse" }, "contact": "Kontakt", "login": "Anmelden", @@ -134,4 +135,4 @@ "notice": "Bitte bestĂ€tigen Sie Ihre E-Mail-Adresse.", "resend": "E-Mail erneut senden" } -} \ No newline at end of file +} diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index e5019de..520e3da 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -194,7 +194,8 @@ "message": "Nachricht", "sending": "Wird gesendet...", "send": "Senden", - "back_home": "ZurĂŒck zur Startseite" + "back_home": "ZurĂŒck zur Startseite", + "success": "Danke! Wir melden uns schnellstmöglich." }, "occasions": { "title": "Fotospiel fĂŒr :type", diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index 35ebd0f..720fc41 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -6,7 +6,8 @@ "occasions": { "wedding": "Wedding", "birthday": "Birthday", - "corporate": "Corporate Event" + "corporate": "Corporate Event", + "label": "Occasions" }, "contact": "Contact", "login": "Login", @@ -135,4 +136,4 @@ "notice": "Please confirm your email address.", "resend": "Resend email" } -} \ No newline at end of file +} diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 144a3ce..e4db96f 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -180,7 +180,8 @@ "message": "Message", "sending": "Sending...", "send": "Send", - "back_home": "Back to Home" + "back_home": "Back to Home", + "success": "Thanks! We will get back to you soon." }, "occasions": { "title": "Fotospiel for :type", diff --git a/resources/css/app.css b/resources/css/app.css index 5f32dd3..ab74627 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -119,6 +119,30 @@ font-display: swap; } +html { + background-color: oklch(1 0 0); +} + +html.dark { + background-color: oklch(0.145 0 0); +} + +@keyframes aurora { + 0%, + 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} + +.bg-aurora { + background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); + background-size: 400% 400%; + animation: aurora 15s ease infinite; +} + /* Basic typography styling for rendered markdown (prose) without Tailwind plugin */ .prose { color: rgb(55 65 81); diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 72f5788..f8de96b 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -666,7 +666,7 @@ export type Package = { }; export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise { - const response = await authorizedFetch(`/api/v1/packages?type=${type}`); + const response = await authorizedFetch(`/api/v1/tenant/packages?type=${type}`); const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages'); return data.data ?? []; } diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx index 11446e6..d3c8d49 100644 --- a/resources/js/admin/auth/context.tsx +++ b/resources/js/admin/auth/context.tsx @@ -4,11 +4,11 @@ import { clearOAuthSession, clearTokens, completeOAuthCallback, - isAuthError, loadTokens, registerAuthFailureHandler, startOAuthFlow, } from './tokens'; +import { ADMIN_LOGIN_PATH } from '../constants'; export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; @@ -58,18 +58,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setUser(profile); setStatus('authenticated'); } catch (error) { - if (isAuthError(error)) { - handleAuthFailure(); - } else { - console.error('[Auth] Failed to refresh profile', error); - } + console.error('[Auth] Failed to refresh profile', error); + handleAuthFailure(); throw error; } }, [handleAuthFailure]); React.useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.has('reset-auth') || window.location.pathname === ADMIN_LOGIN_PATH) { + clearTokens(); + clearOAuthSession(); + setUser(null); + setStatus('unauthenticated'); + } + const tokens = loadTokens(); if (!tokens) { + setUser(null); setStatus('unauthenticated'); return; } @@ -77,7 +83,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children refreshProfile().catch(() => { // refreshProfile already handled failures. }); - }, [refreshProfile]); + }, [handleAuthFailure, refreshProfile]); const login = React.useCallback((redirectPath?: string) => { const target = redirectPath ?? window.location.pathname + window.location.search; diff --git a/resources/js/admin/auth/tokens.ts b/resources/js/admin/auth/tokens.ts index 9943f03..0317542 100644 --- a/resources/js/admin/auth/tokens.ts +++ b/resources/js/admin/auth/tokens.ts @@ -166,8 +166,15 @@ export async function startOAuthFlow(redirectPath?: string): Promise { sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); sessionStorage.setItem(STATE_KEY, state); + localStorage.setItem(CODE_VERIFIER_KEY, verifier); + localStorage.setItem(STATE_KEY, state); if (redirectPath) { sessionStorage.setItem(REDIRECT_KEY, redirectPath); + localStorage.setItem(REDIRECT_KEY, redirectPath); + } + + if (import.meta.env.DEV) { + console.debug('[Auth] PKCE store', { state, verifier, redirectPath }); } const params = new URLSearchParams({ @@ -190,16 +197,23 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise (stripePublishableKey ? loadStripe(stripePublishableKey) : null), + [stripePublishableKey] + ); const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined; const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null; @@ -335,7 +338,7 @@ export default function WelcomeOrderSummaryPage() { return () => { cancelled = true; }; - }, [requiresPayment, packageDetails, t]); + }, [requiresPayment, packageDetails, stripePromise, t]); const priceText = progress.selectedPackage?.priceText ?? diff --git a/resources/js/admin/pages/AuthCallbackPage.tsx b/resources/js/admin/pages/AuthCallbackPage.tsx index c68afaf..dc8b50f 100644 --- a/resources/js/admin/pages/AuthCallbackPage.tsx +++ b/resources/js/admin/pages/AuthCallbackPage.tsx @@ -8,8 +8,14 @@ export default function AuthCallbackPage() { const { completeLogin } = useAuth(); const navigate = useNavigate(); const [error, setError] = React.useState(null); + const hasHandledRef = React.useRef(false); React.useEffect(() => { + if (hasHandledRef.current) { + return; + } + hasHandledRef.current = true; + const params = new URLSearchParams(window.location.search); completeLogin(params) .then((redirectTo) => { diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 35424d6..bb6297a 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react'; +import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -220,22 +220,30 @@ export default function EventFormPage() {
- + + {packagesLoading ? ( +

Pakete werden geladen...

+ ) : null} + {!packagesLoading && (!packages || packages.length === 0) ? ( +

Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.

+ ) : null} diff --git a/resources/js/admin/pages/LogoutPage.tsx b/resources/js/admin/pages/LogoutPage.tsx new file mode 100644 index 0000000..c3f1714 --- /dev/null +++ b/resources/js/admin/pages/LogoutPage.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useAuth } from '../auth/context'; +import { ADMIN_PUBLIC_LANDING_PATH } from '../constants'; + +export default function LogoutPage() { + const { logout } = useAuth(); + + React.useEffect(() => { + logout({ redirect: ADMIN_PUBLIC_LANDING_PATH }); + }, [logout]); + + return ( +
+
+ Abmeldung wird vorbereitet ... +
+
+ ); +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 24aa834..d2619c6 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -15,6 +15,7 @@ import TaskCollectionsPage from './pages/TaskCollectionsPage'; import EmotionsPage from './pages/EmotionsPage'; import AuthCallbackPage from './pages/AuthCallbackPage'; import WelcomeTeaserPage from './pages/WelcomeTeaserPage'; +import LogoutPage from './pages/LogoutPage'; import { useAuth } from './auth/context'; import { ADMIN_BASE_PATH, @@ -71,6 +72,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'login', element: }, + { path: 'logout', element: }, { path: 'auth/callback', element: }, { element: , @@ -92,7 +94,6 @@ export const router = createBrowserRouter([ { path: 'welcome/packages', element: }, { path: 'welcome/summary', element: }, { path: 'welcome/event', element: }, - { path: '', element: }, ], }, ], @@ -102,4 +103,3 @@ export const router = createBrowserRouter([ element: , }, ]); - diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 403b252..bf1a587 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -8,15 +8,10 @@ import AppLayout from './layouts/app/AppLayout'; import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; import { Toaster } from 'react-hot-toast'; -import { Elements } from '@stripe/react-stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; import { ConsentProvider } from './contexts/consent'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; -// Initialize Stripe -const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); - createInertiaApp({ title: (title) => title ? `${title} - ${appName}` : appName, resolve: (name) => resolvePageComponent( @@ -42,14 +37,12 @@ createInertiaApp({ } root.render( - - - - - - - - + + + + + + ); }, progress: { diff --git a/resources/js/components/analytics/MatomoTracker.tsx b/resources/js/components/analytics/MatomoTracker.tsx index 4b88328..12a3ba8 100644 --- a/resources/js/components/analytics/MatomoTracker.tsx +++ b/resources/js/components/analytics/MatomoTracker.tsx @@ -21,6 +21,7 @@ interface MatomoTrackerProps { const MatomoTracker: React.FC = ({ config }) => { const page = usePage(); const { hasConsent } = useConsent(); + const scriptNonce = (page.props.security as { csp?: { scriptNonce?: string } } | undefined)?.csp?.scriptNonce; const analyticsConsent = hasConsent('analytics'); useEffect(() => { @@ -55,6 +56,19 @@ const MatomoTracker: React.FC = ({ config }) => { script.async = true; script.src = `${base}/matomo.js`; script.dataset.matomo = base; + if (scriptNonce) { + script.setAttribute('nonce', scriptNonce); + } else if (typeof window !== 'undefined' && (window as any).__CSP_NONCE) { + script.setAttribute('nonce', (window as any).__CSP_NONCE); + } else { + const metaNonce = document + .querySelector('meta[name="csp-nonce"]') + ?.getAttribute('content'); + + if (metaNonce) { + script.setAttribute('nonce', metaNonce); + } + } document.body.appendChild(script); } diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 2c689f5..80a6215 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -59,6 +59,16 @@ export const messages: Record = { description: 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten moeglich.', }, + access_rate_limited: { + title: 'Zu viele Aufrufe', + description: 'Es gab sehr viele Aufrufe in kurzer Zeit. Warte kurz und versuche es erneut.', + hint: 'Tipp: Du kannst es gleich noch einmal versuchen.', + }, + gallery_expired: { + title: 'Galerie nicht mehr verfuegbar', + description: 'Die Galerie zu diesem Event ist nicht mehr zugaenglich.', + ctaLabel: 'Neuen Code anfordern', + }, event_not_public: { title: 'Event nicht oeffentlich', description: 'Dieses Event ist aktuell nicht oeffentlich zugaenglich.', @@ -404,6 +414,16 @@ export const messages: Record = { description: 'There were too many attempts in a short time. Wait a bit and try again.', hint: 'Tip: You can retry in a few minutes.', }, + access_rate_limited: { + title: 'Too many requests', + description: 'There were too many requests in a short time. Please wait a moment and try again.', + hint: 'Tip: You can retry shortly.', + }, + gallery_expired: { + title: 'Gallery unavailable', + description: 'The gallery for this event is no longer accessible.', + ctaLabel: 'Request new code', + }, event_not_public: { title: 'Event not public', description: 'This event is not publicly accessible right now.', diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 171f876..e5240e8 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -197,8 +197,12 @@ function getErrorContent( return build('token_expired', { ctaHref: '/event' }); case 'token_rate_limited': return build('token_rate_limited'); + case 'access_rate_limited': + return build('access_rate_limited'); case 'event_not_public': return build('event_not_public'); + case 'gallery_expired': + return build('gallery_expired', { ctaHref: '/event' }); case 'network_error': return build('network_error'); case 'server_error': @@ -219,4 +223,3 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
); } - diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index 4dcd23a..ac548f8 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -67,6 +67,8 @@ const API_ERROR_CODES: FetchEventErrorCode[] = [ 'token_expired', 'token_revoked', 'token_rate_limited', + 'access_rate_limited', + 'gallery_expired', 'event_not_public', ]; @@ -78,9 +80,9 @@ function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode } } - if (status === 429) return 'token_rate_limited'; + if (status === 429) return rawCode === 'access_rate_limited' ? 'access_rate_limited' : 'token_rate_limited'; if (status === 404) return 'event_not_public'; - if (status === 410) return 'token_expired'; + if (status === 410) return rawCode === 'gallery_expired' ? 'gallery_expired' : 'token_expired'; if (status === 401) return 'invalid_token'; if (status === 403) return 'token_revoked'; if (status >= 500) return 'server_error'; @@ -98,6 +100,10 @@ function defaultMessageForCode(code: FetchEventErrorCode): string { return 'Dieser Zugriffscode ist abgelaufen.'; case 'token_rate_limited': return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.'; + case 'access_rate_limited': + return 'Zu viele Aufrufe in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.'; + case 'gallery_expired': + return 'Die Galerie ist nicht mehr verfĂŒgbar.'; case 'event_not_public': return 'Dieses Event ist nicht öffentlich verfĂŒgbar.'; case 'network_error': diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index ef21509..e1fdb13 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -243,7 +243,10 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, const [intentRefreshKey, setIntentRefreshKey] = useState(0); const [processingProvider, setProcessingProvider] = useState(null); - const stripePromise = useMemo(() => loadStripe(stripePublishableKey), [stripePublishableKey]); + const stripePromise = useMemo( + () => (stripePublishableKey ? loadStripe(stripePublishableKey) : null), + [stripePublishableKey] + ); const isFree = useMemo(() => (selectedPackage ? selectedPackage.price <= 0 : false), [selectedPackage]); const isReseller = selectedPackage?.type === 'reseller'; @@ -299,6 +302,12 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, return; } + if (!stripePromise) { + setStatus('error'); + setStatusDetail(t('checkout.payment_step.stripe_not_loaded')); + return; + } + if (!authUser) { setStatus('error'); setStatusDetail(t('checkout.payment_step.auth_required')); @@ -351,7 +360,7 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, return () => { cancelled = true; }; - }, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, t]); + }, [authUser, intentRefreshKey, isFree, paymentMethod, paypalDisabled, resetPaymentState, selectedPackage, stripePromise, t]); const providerLabel = useCallback((provider: Provider) => { switch (provider) { @@ -457,7 +466,7 @@ export const PaymentStep: React.FC = ({ stripePublishableKey, {renderStatusAlert()} - {paymentMethod === 'stripe' && clientSecret && ( + {paymentMethod === 'stripe' && clientSecret && stripePromise && ( 'Layout-Übersicht öffnen', 'token_expiry' => 'LĂ€uft ab am :date', ], + 'analytics' => [ + 'success_total' => 'Erfolgreiche Zugriffe', + 'failure_total' => 'Fehlgeschlagene Zugriffe', + 'rate_limited_total' => 'Rate-Limit erreicht', + 'recent_24h' => 'Aufrufe (24h)', + 'last_seen_at' => 'Letzte AktivitĂ€t: :date', + ], ], 'legal_pages' => [ @@ -329,6 +336,77 @@ return [ ], ], + 'refresh_tokens' => [ + 'menu' => 'Refresh Tokens', + 'single' => 'Refresh Token', + 'fields' => [ + 'tenant' => 'Mandant', + 'client' => 'Client', + 'status' => 'Status', + 'revoked_reason' => 'Widerrufsgrund', + 'created_at' => 'Erstellt', + 'last_used_at' => 'Zuletzt verwendet', + 'expires_at' => 'GĂŒltig bis', + 'ip_address' => 'IP-Adresse', + 'user_agent' => 'User Agent', + 'note' => 'Notiz', + ], + 'status' => [ + 'active' => 'Aktiv', + 'revoked' => 'Widerrufen', + 'expired' => 'Abgelaufen', + ], + 'filters' => [ + 'status' => 'Status', + 'tenant' => 'Mandant', + ], + 'actions' => [ + 'revoke' => 'Token widerrufen', + ], + 'reasons' => [ + 'manual' => 'Manuell', + 'operator' => 'Operator-Aktion', + 'rotated' => 'Automatisch rotiert', + 'ip_mismatch' => 'IP-Abweichung', + 'expired' => 'Abgelaufen', + 'invalid_secret' => 'UngĂŒltiges Secret', + 'tenant_missing' => 'Mandant entfernt', + 'max_active_limit' => 'Maximale Anzahl ĂŒberschritten', + ], + 'sections' => [ + 'details' => 'Token-Details', + 'security' => 'Sicherheitskontext', + ], + 'audit' => [ + 'heading' => 'Audit-Log', + 'event' => 'Ereignis', + 'events' => [ + 'issued' => 'Ausgestellt', + 'refresh_attempt' => 'Refresh versucht', + 'refreshed' => 'Refresh erfolgreich', + 'client_mismatch' => 'Client stimmt nicht ĂŒberein', + 'invalid_secret' => 'UngĂŒltiges Secret', + 'ip_mismatch' => 'IP-Abweichung', + 'expired' => 'Abgelaufen', + 'revoked' => 'Widerrufen', + 'rotated' => 'Rotiert', + 'tenant_missing' => 'Mandant fehlt', + 'max_active_limit' => 'Begrenzung erreicht', + ], + 'performed_by' => 'AusgefĂŒhrt von', + 'ip_address' => 'IP-Adresse', + 'context' => 'Kontext', + 'performed_at' => 'Zeitpunkt', + 'empty' => [ + 'heading' => 'Noch keine EintrĂ€ge', + 'description' => 'Sobald das Token verwendet wird, erscheinen hier EintrĂ€ge.', + ], + ], + 'notifications' => [ + 'revoked' => 'Refresh Token wurde widerrufen.', + ], + ], + 'shell' => [ 'tenant_admin_title' => 'Tenant‑Admin', ], diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index 2c9ec97..d620457 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -49,4 +49,11 @@ return [ 'subject' => 'Neue Kontakt-Anfrage', 'body' => 'Kontakt-Anfrage von :name (:email): :message', ], -]; \ No newline at end of file + + 'contact_confirmation' => [ + 'subject' => 'Vielen Dank fĂŒr Ihre Nachricht, :name!', + 'greeting' => 'Hallo :name,', + 'body' => 'Vielen Dank fĂŒr Ihre Nachricht an das Fotospiel-Team. Wir melden uns so schnell wie möglich zurĂŒck.', + 'footer' => 'Viele GrĂŒĂŸe
Ihr Fotospiel-Team', + ], +]; diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index d87af45..19c4614 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -160,4 +160,7 @@ return [ 'currency' => [ 'euro' => '€', ], + 'contact' => [ + 'success' => 'Danke! Wir melden uns schnellstmöglich.', + ], ]; diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 1c5c9a3..d9c336d 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -105,6 +105,13 @@ return [ 'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the join tokens below or manage QR layouts in the admin app.', 'open_admin' => 'Open admin app', ], + 'analytics' => [ + 'success_total' => 'Successful checks', + 'failure_total' => 'Failures', + 'rate_limited_total' => 'Rate limited', + 'recent_24h' => 'Requests (24h)', + 'last_seen_at' => 'Last activity: :date', + ], ], 'legal_pages' => [ @@ -315,6 +322,77 @@ return [ ], ], + 'refresh_tokens' => [ + 'menu' => 'Refresh tokens', + 'single' => 'Refresh token', + 'fields' => [ + 'tenant' => 'Tenant', + 'client' => 'Client', + 'status' => 'Status', + 'revoked_reason' => 'Revoked reason', + 'created_at' => 'Created', + 'last_used_at' => 'Last used', + 'expires_at' => 'Expires at', + 'ip_address' => 'IP address', + 'user_agent' => 'User agent', + 'note' => 'Operator note', + ], + 'status' => [ + 'active' => 'Active', + 'revoked' => 'Revoked', + 'expired' => 'Expired', + ], + 'filters' => [ + 'status' => 'Status', + 'tenant' => 'Tenant', + ], + 'actions' => [ + 'revoke' => 'Revoke token', + ], + 'reasons' => [ + 'manual' => 'Manual', + 'operator' => 'Operator action', + 'rotated' => 'Rotated (auto)', + 'ip_mismatch' => 'IP mismatch', + 'expired' => 'Expired', + 'invalid_secret' => 'Invalid secret attempt', + 'tenant_missing' => 'Tenant removed', + 'max_active_limit' => 'Exceeded active token limit', + ], + 'sections' => [ + 'details' => 'Token details', + 'security' => 'Security context', + ], + 'audit' => [ + 'heading' => 'Audit log', + 'event' => 'Event', + 'events' => [ + 'issued' => 'Issued', + 'refresh_attempt' => 'Refresh attempted', + 'refreshed' => 'Refresh succeeded', + 'client_mismatch' => 'Client mismatch', + 'invalid_secret' => 'Invalid secret', + 'ip_mismatch' => 'IP mismatch', + 'expired' => 'Expired', + 'revoked' => 'Revoked', + 'rotated' => 'Rotated', + 'tenant_missing' => 'Tenant missing', + 'max_active_limit' => 'Pruned (active limit)', + ], + 'performed_by' => 'Actor', + 'ip_address' => 'IP address', + 'context' => 'Context', + 'performed_at' => 'Timestamp', + 'empty' => [ + 'heading' => 'No audit entries yet', + 'description' => 'Token activity will appear here once it is used.', + ], + ], + 'notifications' => [ + 'revoked' => 'Refresh token revoked.', + ], + ], + 'shell' => [ 'tenant_admin_title' => 'Tenant Admin', ], diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index e7961e8..f60b298 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -49,4 +49,11 @@ return [ 'subject' => 'New Contact Request', 'body' => 'Contact request from :name (:email): :message', ], -]; \ No newline at end of file + + 'contact_confirmation' => [ + 'subject' => 'Thank you for reaching out, :name!', + 'greeting' => 'Hi :name,', + 'body' => 'Thank you for your message to the Fotospiel team. We will get back to you as soon as possible.', + 'footer' => 'Best regards,
The Fotospiel Team', + ], +]; diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index e33df1f..ea990ff 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -160,4 +160,7 @@ return [ 'currency' => [ 'euro' => '€', ], + 'contact' => [ + 'success' => 'Thanks! We will get back to you soon.', + ], ]; diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index 718c6ae..b773b16 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -11,7 +11,7 @@ @viteReactRefresh - @vite('resources/js/admin/main.tsx') + @vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 8fbdc96..b59b2ab 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -1,14 +1,22 @@ +@php + $scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce'); +@endphp + ($appearance ?? 'system') == 'dark'])> + @if($scriptNonce) + + @endif {{-- Inline script to detect system dark mode preference and apply it immediately --}} - - {{-- Inline style to set the HTML background color based on our theme in app.css --}} - - {{ config('app.name', 'Laravel') }} @@ -41,7 +38,7 @@ @viteReactRefresh - @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) + @vite(['resources/css/app.css', 'resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) @inertiaHead diff --git a/resources/views/emails/abandoned-checkout.blade.php b/resources/views/emails/abandoned-checkout.blade.php index 2a114ea..57fa8b8 100644 --- a/resources/views/emails/abandoned-checkout.blade.php +++ b/resources/views/emails/abandoned-checkout.blade.php @@ -1,7 +1,7 @@ - {{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $package->name]) }} + {{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $packageName]) }} diff --git a/resources/views/marketing/packages.blade.php b/resources/views/marketing/packages.blade.php index 2985a89..ae29cde 100644 --- a/resources/views/marketing/packages.blade.php +++ b/resources/views/marketing/packages.blade.php @@ -186,7 +186,7 @@ @endsection @push('scripts') - -@endpush \ No newline at end of file +@endpush diff --git a/resources/views/marketing/success.blade.php b/resources/views/marketing/success.blade.php index 50e4c3a..d8d90f1 100644 --- a/resources/views/marketing/success.blade.php +++ b/resources/views/marketing/success.blade.php @@ -6,7 +6,7 @@
@auth @if(auth()->user()->email_verified_at) -
diff --git a/routes/api.php b/routes/api.php index c617f0b..cf86e9d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -43,7 +43,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('/gallery/{token}/photos', [EventPublicController::class, 'galleryPhotos'])->name('gallery.photos'); Route::get('/gallery/{token}/photos/{photo}/download', [EventPublicController::class, 'galleryPhotoDownload']) ->whereNumber('photo') + ->middleware('signed') ->name('gallery.photos.download'); + Route::get('/gallery/{token}/photos/{photo}/{variant}', [EventPublicController::class, 'galleryPhotoAsset']) + ->whereNumber('photo') + ->where('variant', 'thumbnail|full') + ->middleware('signed') + ->name('gallery.photos.asset'); }); Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { diff --git a/routes/web.php b/routes/web.php index deae2e0..93c10a1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -42,9 +42,16 @@ Route::get('/blog/{slug}', [MarketingController::class, 'blogShow'])->name('blog Route::get('/packages', [MarketingController::class, 'packagesIndex'])->name('packages'); Route::get('/anlaesse/{type}', [MarketingController::class, 'occasionsType'])->name('anlaesse.type'); Route::get('/success/{packageId?}', [MarketingController::class, 'success'])->name('marketing.success'); +Route::view('/event-admin/auth/callback', 'admin')->name('tenant.admin.auth.callback'); +Route::view('/event-admin/login', 'admin')->name('tenant.admin.login'); +Route::view('/event-admin/logout', 'admin')->name('tenant.admin.logout'); Route::view('/event-admin/{view?}', 'admin')->where('view', '.*')->name('tenant.admin.app'); Route::view('/event', 'guest')->name('guest.pwa.landing'); Route::view('/g/{token}', 'guest')->where('token', '.*')->name('guest.gallery'); +Route::view('/e/{token}/{path?}', 'guest') + ->where('token', '.*') + ->where('path', '.*') + ->name('guest.event'); Route::middleware('auth')->group(function () { Route::get('/buy/{packageId}', [MarketingController::class, 'buyPackages'])->name('marketing.buy'); }); diff --git a/vite.config.ts b/vite.config.ts index 563b929..a787a21 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,12 @@ export default defineConfig({ // Falls ihr auf gemounteten FS seid und Events fehlen: // usePolling: true, interval: 500, }, + proxy: { + '/fonts': { + target: appUrl, + changeOrigin: true, + }, + }, }, plugins: [ laravel({