Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty

states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
    (resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
  - Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
    routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
    resources/js/admin/router.tsx, routes/web.php)
This commit is contained in:
Codex Agent
2025-10-19 23:00:47 +02:00
parent a949c8d3af
commit 6290a3a448
95 changed files with 3708 additions and 394 deletions

View File

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

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthListKeysCommand extends Command
{
protected $signature = 'oauth:list-keys {--json : Output as JSON for scripting}';
protected $description = 'List available JWT signing key directories and their status.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->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;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class OAuthPruneKeysCommand extends Command
{
protected $signature = 'oauth:prune-keys
{--days=90 : Prune keys whose directories were last modified before this many days ago}
{--dry-run : Show which keys would be removed without deleting}
{--force : Skip confirmation prompt}';
protected $description = 'Remove legacy JWT signing keys older than the configured threshold.';
public function handle(): int
{
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
if (! File::exists($storage)) {
$this->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;
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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,

View File

@@ -0,0 +1,200 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource\RelationManagers\AuditsRelationManager;
use App\Models\RefreshToken;
use BackedEnum;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Database\Eloquent\Builder;
class RefreshTokenResource extends Resource
{
protected static ?string $model = RefreshToken::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?int $navigationSort = 32;
public static function getNavigationGroup(): string
{
return __('admin.nav.security');
}
public static function getNavigationLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getPluralLabel(): string
{
return __('admin.refresh_tokens.menu');
}
public static function getModelLabel(): string
{
return __('admin.refresh_tokens.single');
}
public static function form(Schema $form): Schema
{
return $form->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}'),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Resources\Pages\ListRecords;
class ListRefreshTokens extends ListRecords
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\Pages;
use App\Filament\Resources\RefreshTokenResource;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Pages\ViewRecord;
class ViewRefreshToken extends ViewRecord
{
protected static string $resource = RefreshTokenResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('revoke')
->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'));
}),
];
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\RefreshTokenResource\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class AuditsRelationManager extends RelationManager
{
protected static string $relationship = 'audits';
public function table(Table $table): Table
{
return $table
->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'));
}
}

View File

@@ -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,

View File

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

View File

@@ -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,
]);
}
});
}
}
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,
]);
}
}

View File

@@ -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([

View File

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

View File

@@ -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([

View File

@@ -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,

View File

@@ -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()

View File

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

View File

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

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$scriptNonce = base64_encode(random_bytes(16));
$styleNonce = null;
$request->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;
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class StripeCSP
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.stripe.com https://js.stripe.network; style-src 'self' 'unsafe-inline' data: https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https://api.stripe.com https://api.stripe.network wss://*.stripe.network; media-src 'self' data: blob:; frame-src 'self' https://js.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}

View File

@@ -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(),

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Jobs;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use App\Services\Security\PhotoSecurityScanner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessPhotoSecurityScan implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(public int $photoId)
{
$this->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,
]);
}
}
}

View File

@@ -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 [];
}
}
private function localizedPackageName(): string
{
$locale = $this->locale ?? app()->getLocale();
return $this->package->getNameForLocale($locale);
}
}

View File

@@ -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]),
);
}

View File

@@ -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 [];
}
}
private function localizedPackageName(): string
{
$locale = $this->locale ?? app()->getLocale();
return optional($this->purchase->package)->getNameForLocale($locale) ?? '';
}
}

View File

@@ -1,9 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\EventJoinTokenEvent;
use Illuminate\Support\Facades\Crypt;
class EventJoinToken extends Model
{
@@ -11,7 +15,9 @@ class EventJoinToken extends Model
protected $fillable = [
'event_id',
'token',
'token_hash',
'token_encrypted',
'token_preview',
'label',
'usage_limit',
'usage_count',
@@ -29,6 +35,15 @@ class EventJoinToken extends Model
'usage_count' => '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);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventJoinTokenEvent extends Model
{
use HasFactory;
protected $fillable = [
'event_join_token_id',
'event_id',
'tenant_id',
'token_hash',
'token_preview',
'event_type',
'route',
'http_method',
'http_status',
'device_id',
'ip_address',
'user_agent',
'context',
'occurred_at',
];
protected $casts = [
'context' => '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);
}
}

View File

@@ -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 [

View File

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

View File

@@ -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(),
]);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshTokenAudit extends Model
{
use HasFactory;
protected $fillable = [
'refresh_token_id',
'tenant_id',
'client_id',
'event',
'context',
'ip_address',
'user_agent',
'performed_by',
'created_at',
];
public $timestamps = false;
protected $casts = [
'context' => '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');
}
}

View File

@@ -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 = [

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Analytics;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class JoinTokenAnalyticsRecorder
{
public function record(
?EventJoinToken $joinToken,
string $eventType,
Request $request,
array $context = [],
?string $providedToken = null,
?int $httpStatus = null
): void {
try {
EventJoinTokenEvent::create($this->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);
}
}

View File

@@ -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()

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Services\Checkout;
use App\Models\CheckoutSession;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CheckoutWebhookService
{
public function __construct(
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {
}
public function handleStripeEvent(array $event): bool
{
$eventType = $event['type'] ?? null;
$intent = $event['data']['object'] ?? null;
if (! $eventType || ! is_array($intent)) {
return false;
}
if (! str_starts_with($eventType, 'payment_intent.')) {
return false;
}
$intentId = $intent['id'] ?? null;
if (! $intentId) {
return false;
}
$session = $this->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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Services\Security;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class PhotoSecurityScanner
{
public function scan(string $disk, ?string $relativePath): array
{
if (! $relativePath) {
return [
'status' => '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()];
}
}
}

View File

@@ -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,
]);

12
config/join_tokens.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
return [
'failure_limit' => (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),
];

View File

@@ -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),
],
];

17
config/security.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
return [
'antivirus' => [
'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'),
],
];

View File

@@ -0,0 +1,95 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$addedTokenHashColumn = false;
Schema::table('event_join_tokens', function (Blueprint $table) use (&$addedTokenHashColumn) {
if (!Schema::hasColumn('event_join_tokens', 'token_hash')) {
$table->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);
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('photos', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('refresh_token_audits', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('refresh_tokens', function (Blueprint $table) {
if (! Schema::hasColumn('refresh_tokens', 'last_used_at')) {
$table->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');
}
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_join_token_events', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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'] ?? []);

View File

@@ -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 5min).
- `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()`.

View File

@@ -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.

View File

@@ -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 <ip_address>
```
- 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.

View File

@@ -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)

View File

@@ -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*

View File

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

View File

@@ -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 25MB 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` (≈6MB). 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: 25MB (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 1MB) 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.

View File

@@ -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.

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

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

View File

@@ -666,7 +666,7 @@ export type Package = {
};
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
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 ?? [];
}

View File

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

View File

@@ -166,8 +166,15 @@ export async function startOAuthFlow(redirectPath?: string): Promise<void> {
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<st
const code = params.get('code');
const returnedState = params.get('state');
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
const expectedState = sessionStorage.getItem(STATE_KEY);
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY) ?? localStorage.getItem(CODE_VERIFIER_KEY);
const expectedState = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
if (import.meta.env.DEV) {
console.debug('[Auth] PKCE debug', { returnedState, expectedState, hasVerifier: !!verifier, params: params.toString() });
}
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
clearOAuthSession();
notifyAuthFailure();
throw new AuthError('invalid_state', 'PKCE state mismatch');
}
sessionStorage.removeItem(CODE_VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
localStorage.removeItem(CODE_VERIFIER_KEY);
localStorage.removeItem(STATE_KEY);
const body = new URLSearchParams({
grant_type: 'authorization_code',
@@ -216,6 +230,7 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
});
if (!response.ok) {
clearOAuthSession();
console.error('[Auth] Authorization code exchange failed', response.status);
notifyAuthFailure();
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
@@ -227,6 +242,9 @@ export async function completeOAuthCallback(params: URLSearchParams): Promise<st
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
if (redirectTarget) {
sessionStorage.removeItem(REDIRECT_KEY);
localStorage.removeItem(REDIRECT_KEY);
} else {
localStorage.removeItem(REDIRECT_KEY);
}
return redirectTarget;
@@ -236,4 +254,7 @@ export function clearOAuthSession(): void {
sessionStorage.removeItem(CODE_VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
sessionStorage.removeItem(REDIRECT_KEY);
localStorage.removeItem(CODE_VERIFIER_KEY);
localStorage.removeItem(STATE_KEY);
localStorage.removeItem(REDIRECT_KEY);
}

View File

@@ -35,7 +35,6 @@ import {
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
const stripePromise = stripePublishableKey ? loadStripe(stripePublishableKey) : null;
type StripeCheckoutProps = {
clientSecret: string;
@@ -268,6 +267,10 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const stripePromise = React.useMemo(
() => (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 ??

View File

@@ -8,8 +8,14 @@ export default function AuthCallbackPage() {
const { completeLogin } = useAuth();
const navigate = useNavigate();
const [error, setError] = React.useState<string | null>(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) => {

View File

@@ -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() {
</div>
<div className="space-y-2">
<Label htmlFor="package_id">Package</Label>
<Select value={form.package_id.toString()} onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value) }))}>
<SelectTrigger>
<SelectValue placeholder="Waehlen Sie ein Package" />
</SelectTrigger>
<SelectContent>
{packagesLoading ? (
<SelectItem value="">Laden...</SelectItem>
) : (
packages?.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))
)}
</SelectContent>
</Select>
<Select
value={form.package_id.toString()}
onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value, 10) }))}
disabled={packagesLoading || !packages?.length}
>
<SelectTrigger>
<SelectValue placeholder={packagesLoading ? 'Pakete werden geladen...' : 'Waehlen Sie ein Package'} />
</SelectTrigger>
{packages?.length ? (
<SelectContent>
{packages.map((pkg) => (
<SelectItem key={pkg.id} value={pkg.id.toString()}>
{pkg.name} - {pkg.price} EUR ({pkg.max_photos} Fotos)
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
{packagesLoading ? (
<p className="text-xs text-slate-500">Pakete werden geladen...</p>
) : null}
{!packagesLoading && (!packages || packages.length === 0) ? (
<p className="text-xs text-red-500">Keine Pakete verfuegbar. Bitte pruefen Sie Ihre Einstellungen.</p>
) : null}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">Package-Details</Button>

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-rose-50 via-white to-slate-50 text-sm text-slate-600">
<div className="rounded-2xl border border-rose-100 bg-white/90 px-6 py-4 shadow-sm shadow-rose-100/60">
Abmeldung wird vorbereitet ...
</div>
</div>
);
}

View File

@@ -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: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'logout', element: <LogoutPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
{
element: <RequireAuth />,
@@ -92,7 +94,6 @@ export const router = createBrowserRouter([
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
{ path: '', element: <Navigate to="dashboard" replace /> },
],
},
],
@@ -102,4 +103,3 @@ export const router = createBrowserRouter([
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
},
]);

View File

@@ -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(
<Elements stripe={stripePromise}>
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<App {...props} />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>
</ConsentProvider>
</Elements>
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<App {...props} />
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>
</ConsentProvider>
);
},
progress: {

View File

@@ -21,6 +21,7 @@ interface MatomoTrackerProps {
const MatomoTracker: React.FC<MatomoTrackerProps> = ({ 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<MatomoTrackerProps> = ({ 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);
}

View File

@@ -59,6 +59,16 @@ export const messages: Record<LocaleCode, NestedMessages> = {
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<LocaleCode, NestedMessages> = {
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.',

View File

@@ -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
</div>
);
}

View File

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

View File

@@ -243,7 +243,10 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey,
const [intentRefreshKey, setIntentRefreshKey] = useState(0);
const [processingProvider, setProcessingProvider] = useState<Provider | null>(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<PaymentStepProps> = ({ 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<PaymentStepProps> = ({ 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<PaymentStepProps> = ({ stripePublishableKey,
{renderStatusAlert()}
{paymentMethod === 'stripe' && clientSecret && (
{paymentMethod === 'stripe' && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripePaymentForm
selectedPackage={selectedPackage}

View File

@@ -28,6 +28,12 @@ export interface SharedData {
auth: Auth;
sidebarOpen: boolean;
supportedLocales?: string[];
security?: {
csp?: {
scriptNonce?: string;
styleNonce?: string;
};
};
[key: string]: unknown;
}

View File

@@ -106,6 +106,13 @@ return [
'layouts_fallback' => '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' => 'TenantAdmin',
],

View File

@@ -49,4 +49,11 @@ return [
'subject' => 'Neue Kontakt-Anfrage',
'body' => 'Kontakt-Anfrage von :name (:email): :message',
],
];
'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<br>Ihr Fotospiel-Team',
],
];

View File

@@ -160,4 +160,7 @@ return [
'currency' => [
'euro' => '€',
],
'contact' => [
'success' => 'Danke! Wir melden uns schnellstmöglich.',
],
];

View File

@@ -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',
],

View File

@@ -49,4 +49,11 @@ return [
'subject' => 'New Contact Request',
'body' => 'Contact request from :name (:email): :message',
],
];
'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,<br>The Fotospiel Team',
],
];

View File

@@ -160,4 +160,7 @@ return [
'currency' => [
'euro' => '€',
],
'contact' => [
'success' => 'Thanks! We will get back to you soon.',
],
];

View File

@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@viteReactRefresh
@vite('resources/js/admin/main.tsx')
@vite(['resources/css/app.css', 'resources/js/admin/main.tsx'])
</head>
<body>
<div id="root"></div>

View File

@@ -1,14 +1,22 @@
@php
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
@if($scriptNonce)
<meta name="csp-nonce" content="{{ $scriptNonce }}">
@endif
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
window.__CSP_NONCE = '{{ $scriptNonce }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
@@ -20,17 +28,6 @@
})();
</script>
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
@@ -41,7 +38,7 @@
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@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
</head>
<body class="font-sans antialiased">

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $package->name]) }}</title>
<title>{{ __('emails.abandoned_checkout.subject_' . $timing, ['package' => $packageName]) }}</title>
<style>
.cta-button {
background-color: #007bff;
@@ -26,7 +26,7 @@
<body>
<h1>{{ __('emails.abandoned_checkout.greeting', ['name' => $user->fullName]) }}</h1>
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $package->name]) }}</p>
<p>{{ __('emails.abandoned_checkout.body_' . $timing, ['package' => $packageName]) }}</p>
<a href="{{ $resumeUrl }}" class="cta-button">
{{ __('emails.abandoned_checkout.cta_button') }}
@@ -44,4 +44,4 @@
<p>{!! __('emails.abandoned_checkout.footer') !!}</p>
</body>
</html>
</html>

View File

@@ -1,10 +1,7 @@
@component('mail::message')
# Hallo {{ $name }},
# {{ __('emails.contact_confirmation.greeting', ['name' => $name]) }}
vielen Dank fuer Ihre Nachricht an das Fotospiel Team. Wir melden uns so schnell wie moeglich bei Ihnen.
{{ __('emails.contact_confirmation.body') }}
Falls Sie weitere Informationen hinzufuegen moechten, antworten Sie einfach auf diese E-Mail.
Viele Gruesse
Ihr Fotospiel Team
{{ __('emails.contact_confirmation.footer') }}
@endcomponent

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ __('emails.purchase.subject', ['package' => $package->name]) }}</title>
<title>{{ __('emails.purchase.subject', ['package' => $packageName]) }}</title>
</head>
<body>
<h1>{{ __('emails.purchase.greeting', ['name' => $user->fullName]) }}</h1>
<p>{{ __('emails.purchase.package', ['package' => $package->name]) }}</p>
<p>{{ __('emails.purchase.package', ['package' => $packageName]) }}</p>
<p>{{ __('emails.purchase.price', ['price' => $purchase->price]) }}</p>
<p>{{ __('emails.purchase.activation') }}</p>
<p>{!! __('emails.purchase.footer') !!}</p>
</body>
</html>
</html>

View File

@@ -68,6 +68,44 @@
</div>
</div>
@php
$analytics = $token['analytics'] ?? [];
@endphp
@if (!empty($analytics))
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.success_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['success_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.failure_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['failure_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.rate_limited_total') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['rate_limited_total'] ?? 0) }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-200">
<div class="text-[11px] uppercase tracking-wide">{{ __('admin.events.analytics.recent_24h') }}</div>
<div class="mt-1 text-lg font-semibold">
{{ number_format($analytics['recent_24h'] ?? 0) }}
</div>
@if (!empty($analytics['last_seen_at']))
<div class="mt-1 text-[11px] text-slate-500 dark:text-slate-400">
{{ __('admin.events.analytics.last_seen_at', ['date' => \Carbon\Carbon::parse($analytics['last_seen_at'])->isoFormat('LLL')]) }}
</div>
@endif
</div>
</div>
@endif
@if (!empty($token['layouts']))
<div class="mt-4 space-y-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">

View File

@@ -1,3 +1,7 @@
@php
$scriptNonce = $cspNonce ?? request()->attributes->get('csp_script_nonce');
@endphp
<!doctype html>
<html lang="de">
<head>
@@ -7,7 +11,13 @@
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
<meta name="csrf-token" content="{{ csrf_token() }}">
@if($scriptNonce)
<meta name="csp-nonce" content="{{ $scriptNonce }}">
@endif
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
<script @if($scriptNonce) nonce="{{ $scriptNonce }}" @endif>
window.__CSP_NONCE = '{{ $scriptNonce }}';
</script>
@php
$currentLocale = app()->getLocale();
@@ -20,17 +30,6 @@
<link rel="alternate" hreflang="{{ $locale }}" href="{{ url("/$locale$path") }}">
@endforeach
<link rel="alternate" hreflang="x-default" href="{{ url('/de' . $path) }}">
<style>
@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;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-50 text-gray-900">

View File

@@ -186,7 +186,7 @@
@endsection
@push('scripts')
<script>
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
document.addEventListener('DOMContentLoaded', function() {
const tabLinks = document.querySelectorAll('.tab-link');
tabLinks.forEach(link => {
@@ -201,4 +201,4 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
</script>
@endpush
@endpush

View File

@@ -6,7 +6,7 @@
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
@auth
@if(auth()->user()->email_verified_at)
<script>
<script @if(isset($cspNonce) || request()->attributes->get('csp_script_nonce')) nonce="{{ $cspNonce ?? request()->attributes->get('csp_script_nonce') }}" @endif>
window.location.href = '/event-admin';
</script>
<div class="text-center">

View File

@@ -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 () {

View File

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

View File

@@ -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({