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

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