ip()); $joinToken = $this->joinTokenService->findToken($token, true); if (! $joinToken) { 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), ], $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(), ], $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, ], $token, $joinToken ); } $columns = array_unique(array_merge($columns, ['status'])); $event = DB::table('events') ->where('id', $joinToken->event_id) ->first($columns); if (! $event) { 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') { Log::notice('Join token event not public', [ 'token' => Str::limit($token, 12), 'event_id' => $event->id ?? null, '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 ApiError::response( 'event_not_public', 'Event Not Public', 'This event is not publicly accessible.', Response::HTTP_FORBIDDEN, [ 'token' => Str::limit($token, 12), 'event_id' => $event->id ?? null, ] ); } RateLimiter::clear($rateLimiterKey); if (isset($event->status)) { 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]; } /** * @return JsonResponse|array{0: Event, 1: EventJoinToken} */ private function resolveGalleryEvent(Request $request, string $token): JsonResponse|array { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$eventRecord, $joinToken] = $result; $event = Event::with(['tenant', 'eventPackage.package', 'eventType'])->find($eventRecord->id); if (! $event) { return ApiError::response( 'event_not_found', 'Event Not Found', 'The event associated with this gallery could not be located.', Response::HTTP_NOT_FOUND, [ 'token' => Str::limit($token, 12), ] ); } $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 ApiError::response( 'gallery_expired', 'Gallery Expired', 'The gallery is no longer available for this event.', Response::HTTP_GONE, [ 'event_id' => $event->id, 'expired_at' => $expiresAt->toIso8601String(), ] ); } $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 = [], ?string $rawToken = null, ?EventJoinToken $joinToken = null ): JsonResponse { $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 ApiError::response( 'token_rate_limited', 'Too Many Attempts', 'Too many invalid join token attempts. Try again later.', Response::HTTP_TOO_MANY_REQUESTS, array_merge($context, ['rate_limiter_key' => $rateLimiterKey]) ); } 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 ApiError::response( $code, $this->tokenErrorTitle($code), $this->tokenErrorMessage($code), $status, $context ); } private function tokenErrorMessage(string $code): string { return match ($code) { 'invalid_token' => 'The provided join token is invalid.', 'token_expired' => 'The join token has expired.', 'token_revoked' => 'The join token has been revoked.', default => 'Access denied.', }; } private function tokenErrorTitle(string $code): string { return match ($code) { 'invalid_token' => 'Invalid Join Token', 'token_expired' => 'Join Token Expired', 'token_revoked' => 'Join Token Revoked', 'token_rate_limited' => 'Join Token Rate Limited', default => 'Access Denied', }; } 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 ApiError::response( 'access_rate_limited', 'Too Many Requests', 'Too many requests. Please slow down.', Response::HTTP_TOO_MANY_REQUESTS, [ 'limit' => $limit, 'decay_minutes' => $decay, ] ); } 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 ApiError::response( 'download_rate_limited', 'Download Rate Limited', 'Download rate limit exceeded. Please wait a moment.', Response::HTTP_TOO_MANY_REQUESTS, [ 'limit' => $limit, 'decay_minutes' => $decay, ] ); } 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); return $data[$locale] ?? $data['de'] ?? $default; } return $value ?: $default; } private function toPublicUrl(?string $path): ?string { if (! $path) { return null; } // Already absolute URL if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } // Already a public storage URL if (str_starts_with($path, '/storage/')) { return $path; } if (str_starts_with($path, 'storage/')) { return '/'.$path; } // Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...') if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/')) { return Storage::url($path); } // Absolute server paths pointing into storage/app/public (Linux/Windows) $normalized = str_replace('\\', '/', $path); $needle = '/storage/app/public/'; if (str_contains($normalized, $needle)) { $rel = substr($normalized, strpos($normalized, $needle) + strlen($needle)); return '/storage/'.ltrim($rel, '/'); } return $path; // fallback as-is } private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string { if (is_array($value)) { $primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null; return $primary ?? (reset($value) ?: $fallback); } if (is_string($value) && $value !== '') { return $value; } return $fallback; } private function buildGalleryBranding(Event $event): array { $defaultPrimary = '#f43f5e'; $defaultSecondary = '#fb7185'; $defaultBackground = '#ffffff'; $eventBranding = Arr::get($event->settings, 'branding', []); $tenantBranding = Arr::get($event->tenant?->settings, 'branding', []); return [ 'primary_color' => Arr::get($eventBranding, 'primary_color') ?? Arr::get($tenantBranding, 'primary_color') ?? $defaultPrimary, 'secondary_color' => Arr::get($eventBranding, 'secondary_color') ?? Arr::get($tenantBranding, 'secondary_color') ?? $defaultSecondary, 'background_color' => Arr::get($eventBranding, 'background_color') ?? Arr::get($tenantBranding, 'background_color') ?? $defaultBackground, ]; } private function encodeGalleryCursor(Photo $photo): string { return base64_encode(json_encode([ 'id' => $photo->id, 'created_at' => $photo->created_at?->toIso8601String(), ])); } private function decodeGalleryCursor(?string $cursor): ?array { if (! $cursor) { return null; } $decoded = json_decode(base64_decode($cursor, true) ?: '', true); if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) { return null; } try { return [ 'id' => (int) $decoded['id'], 'created_at' => Carbon::parse($decoded['created_at']), ]; } catch (\Throwable $e) { return null; } } private function makeGalleryPhotoResource(Photo $photo, string $token): array { $thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail'); $fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full'); $downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo); return [ 'id' => $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()); $resolved = $this->resolveGalleryEvent($request, $token); if ($resolved instanceof JsonResponse) { return $resolved; } /** @var array{0: Event, 1: EventJoinToken} $resolved */ [$event, $joinToken] = $resolved; if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) { return $downloadResponse; } $branding = $this->buildGalleryBranding($event); $expiresAt = optional($event->eventPackage)->gallery_expires_at; return response()->json([ 'event' => [ 'id' => $event->id, 'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'), 'slug' => $event->slug, 'description' => $this->translateLocalized($event->description, $locale, ''), 'gallery_expires_at' => $expiresAt?->toIso8601String(), ], 'branding' => $branding, ]); } public function galleryPhotos(Request $request, string $token) { $resolved = $this->resolveGalleryEvent($request, $token); if ($resolved instanceof JsonResponse) { return $resolved; } /** @var array{0: Event, 1: EventJoinToken} $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)); $cursor = $this->decodeGalleryCursor($request->query('cursor')); $query = Photo::query() ->where('event_id', $event->id) ->where('status', 'approved') ->orderByDesc('created_at') ->orderByDesc('id'); if ($cursor) { /** @var Carbon $cursorTime */ $cursorTime = $cursor['created_at']; $cursorId = $cursor['id']; $query->where(function ($inner) use ($cursorTime, $cursorId) { $inner->where('created_at', '<', $cursorTime) ->orWhere(function ($nested) use ($cursorTime, $cursorId) { $nested->where('created_at', '=', $cursorTime) ->where('id', '<', $cursorId); }); }); } $photos = $query->limit($limit + 1)->get(); $hasMore = $photos->count() > $limit; $items = $photos->take($limit); $nextCursor = null; if ($hasMore) { $cursorPhoto = $photos->slice($limit, 1)->first(); if ($cursorPhoto instanceof Photo) { $nextCursor = $this->encodeGalleryCursor($cursorPhoto); } } return response()->json([ 'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(), 'next_cursor' => $nextCursor, ]); } 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 ApiError::response( 'photo_not_found', 'Photo Not Found', 'The requested photo is no longer available.', Response::HTTP_NOT_FOUND, [ 'photo_id' => $photo, 'event_id' => $event->id, ] ); } $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); 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 ApiError::response( 'photo_not_found', 'Photo Not Found', 'The requested photo is no longer available.', Response::HTTP_NOT_FOUND, [ 'photo_id' => $photo, 'event_id' => $event->id, ] ); } return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment'); } public function event(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, [ 'id', 'slug', 'name', 'default_locale', 'created_at', 'updated_at', 'event_type_id', ]); if ($result instanceof JsonResponse) { return $result; } [$eventRecord, $joinToken] = $result; $event = Event::with(['tenant', 'eventPackage.package'])->find($eventRecord->id); if (! $event) { return ApiError::response( 'event_not_found', 'Event Not Found', 'Das Event konnte nicht gefunden werden.', Response::HTTP_NOT_FOUND, ['token' => Str::limit($token, 12)] ); } $locale = $request->query('locale', $event->default_locale ?? 'de'); $localizedName = $this->translateLocalized($event->name, $locale, 'Fotospiel Event'); $eventType = $event->eventType; $eventTypeData = $eventType ? [ 'slug' => $eventType->slug, 'name' => $this->translateLocalized($eventType->name, $locale, 'Event'), 'icon' => $eventType->icon ?? null, ] : [ 'slug' => 'general', 'name' => $this->getLocalized('Event', $locale, 'Event'), 'icon' => null, ]; $branding = $this->buildGalleryBranding($event); $fontFamily = Arr::get($event->settings, 'branding.font_family') ?? Arr::get($event->tenant?->settings, 'branding.font_family'); $logoUrl = Arr::get($event->settings, 'branding.logo_url') ?? Arr::get($event->tenant?->settings, 'branding.logo_url'); if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); } return response()->json([ 'id' => $event->id, 'slug' => $event->slug, 'name' => $localizedName, 'default_locale' => $event->default_locale, 'created_at' => $event->created_at, 'updated_at' => $event->updated_at, 'type' => $eventTypeData, 'join_token' => $joinToken?->token, 'branding' => [ 'primary_color' => $branding['primary_color'], 'secondary_color' => $branding['secondary_color'], 'background_color' => $branding['background_color'], 'font_family' => $fontFamily, 'logo_url' => $this->toPublicUrl($logoUrl), ], ])->header('Cache-Control', 'no-store'); } public function package(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$eventRecord, $joinToken] = $result; $event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package']) ->findOrFail($eventRecord->id); if (! $event->tenant) { return ApiError::response( 'event_not_found', 'Event not accessible', 'The selected event is no longer available.', Response::HTTP_NOT_FOUND, ['scope' => 'photos', 'event_id' => $event->id] ); } $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload( $event->tenant, $event->id, $event ); if (! $eventPackage || ! $eventPackage->package) { return response()->json([ 'id' => null, 'event_id' => $event->id, 'package_id' => null, 'package' => null, 'used_photos' => (int) ($eventPackage?->used_photos ?? 0), 'used_guests' => (int) ($eventPackage?->used_guests ?? 0), 'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(), 'limits' => null, ])->header('Cache-Control', 'no-store'); } $package = $eventPackage->package; $summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage); return response()->json([ 'id' => $eventPackage->id, 'event_id' => $event->id, 'package_id' => $eventPackage->package_id, 'package' => [ 'id' => $eventPackage->package_id, 'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name, 'max_photos' => $package?->max_photos, 'max_guests' => $package?->max_guests, 'gallery_days' => $package?->gallery_days, ], 'used_photos' => (int) $eventPackage->used_photos, 'used_guests' => (int) $eventPackage->used_guests, 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), 'limits' => $summary, ])->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 ApiError::response( 'photo_unavailable', 'Photo Unavailable', 'The requested photo could not be loaded.', Response::HTTP_NOT_FOUND, [ 'photo_id' => $record->id, 'event_id' => $event->id, ] ); } 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']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); // Approximate online guests as distinct recent uploaders in last 10 minutes. $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); $onlineGuests = DB::table('photos') ->where('event_id', $eventId) ->where('created_at', '>=', $tenMinutesAgo) ->distinct('guest_name') ->count('guest_name'); // Tasks solved as number of photos linked to a task (proxy metric). $tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); $payload = [ 'online_guests' => $onlineGuests, 'tasks_solved' => $tasksSolved, 'latest_photo_at' => $latestPhotoAt, ]; $etag = sha1(json_encode($payload)); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag); } public function emotions(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $locale = $request->query('locale', 'de'); $rows = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id') ->join('events', 'events.event_type_id', '=', 'event_types.id') ->where('events.id', $eventId) ->select([ 'emotions.id', 'emotions.name', 'emotions.icon as emoji', 'emotions.description', ]) ->orderBy('emotions.sort_order') ->get(); $payload = $rows->map(function ($r) use ($locale) { $nameData = json_decode($r->name, true); $name = $nameData[$locale] ?? $nameData['de'] ?? $r->name; $descriptionData = json_decode($r->description, true); $description = $descriptionData ? ($descriptionData[$locale] ?? $descriptionData['de'] ?? '') : ''; return [ 'id' => (int) $r->id, 'slug' => 'emotion-'.$r->id, // Generate slug from ID 'name' => $name, 'emoji' => $r->emoji, 'description' => $description, ]; }); $etag = sha1($payload->toJson()); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag); } public function tasks(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $query = DB::table('tasks') ->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') ->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') ->where('event_task_collection.event_id', $eventId) ->select([ 'tasks.id', 'tasks.title', 'tasks.description', 'tasks.example_text as instructions', 'tasks.emotion_id', 'tasks.sort_order', ]) ->orderBy('event_task_collection.sort_order') ->orderBy('tasks.sort_order') ->limit(20); $rows = $query->get(); $locale = $request->query('locale', 'de'); $payload = $rows->map(function ($r) use ($locale) { // Handle JSON fields for multilingual content $getLocalized = function ($field, $default = '') use ($r, $locale) { $value = $r->$field; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); return $data[$locale] ?? $data['de'] ?? $default; } return $value ?: $default; }; // Get emotion info if emotion_id exists $emotion = null; if ($r->emotion_id) { $emotionRow = DB::table('emotions')->where('id', $r->emotion_id)->first(['name']); if ($emotionRow && isset($emotionRow->name)) { $value = $emotionRow->name; if (is_string($value) && json_decode($value) !== null) { $data = json_decode($value, true); $emotionName = $data[$locale] ?? $data['de'] ?? 'Unbekannte Emotion'; } else { $emotionName = $value ?: 'Unbekannte Emotion'; } $emotion = [ 'slug' => 'emotion-'.$r->emotion_id, // Generate slug from ID 'name' => $emotionName, ]; } } return [ 'id' => (int) $r->id, 'title' => $getLocalized('title', 'Unbenannte Aufgabe'), 'description' => $getLocalized('description', ''), 'instructions' => $getLocalized('instructions', ''), 'duration' => 3, // Default 3 minutes (no duration field in DB) 'is_completed' => false, // Default false (no is_completed field in DB) 'emotion' => $emotion, ]; }); // If no tasks found, return empty array instead of 404 if ($payload->isEmpty()) { $payload = collect([]); } $etag = sha1($payload->toJson()); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag); } public function photos(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $deviceId = (string) $request->header('X-Device-Id', 'anon'); $filter = $request->query('filter'); $since = $request->query('since'); $query = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->select([ 'photos.id', 'photos.file_path', 'photos.thumbnail_path', 'photos.likes_count', 'photos.emotion_id', 'photos.task_id', 'photos.guest_name', 'tasks.title as task_title', ]) ->where('photos.event_id', $eventId) ->orderByDesc('photos.created_at') ->limit(60); // MyPhotos filter if ($filter === 'myphotos' && $deviceId !== 'anon') { $query->where('guest_name', $deviceId); } if ($since) { $query->where('photos.created_at', '>', $since); } $locale = $request->query('locale', 'de'); $rows = $query->get()->map(function ($r) use ($locale) { $r->file_path = $this->toPublicUrl((string) ($r->file_path ?? '')); $r->thumbnail_path = $this->toPublicUrl((string) ($r->thumbnail_path ?? '')); // Localize task title if present if ($r->task_title) { $r->task_title = $this->getLocalized($r->task_title, $locale, 'Unbenannte Aufgabe'); } return $r; }); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); $payload = [ 'data' => $rows, 'latest_photo_at' => $latestPhotoAt, ]; $etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt])); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) ->header('Last-Modified', (string) $latestPhotoAt); } public function photo(int $id) { $row = DB::table('photos') ->join('events', 'photos.event_id', '=', 'events.id') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->select([ 'photos.id', 'photos.event_id', 'photos.file_path', 'photos.thumbnail_path', 'photos.likes_count', 'photos.emotion_id', 'photos.task_id', 'photos.created_at', 'tasks.title as task_title', ]) ->where('photos.id', $id) ->where('events.status', 'published') ->first(); if (! $row) { return ApiError::response( 'photo_not_found', 'Photo Not Found', 'Photo not found or event not public.', Response::HTTP_NOT_FOUND, ['photo_id' => $id] ); } $row->file_path = $this->toPublicUrl((string) ($row->file_path ?? '')); $row->thumbnail_path = $this->toPublicUrl((string) ($row->thumbnail_path ?? '')); // Localize task title if present $locale = request()->query('locale', 'de'); if ($row->task_title) { $row->task_title = $this->getLocalized($row->task_title, $locale, 'Unbenannte Aufgabe'); } return response()->json($row)->header('Cache-Control', 'no-store'); } public function like(Request $request, int $id) { $deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64); if ($deviceId === '') { $deviceId = 'anon'; } $photo = DB::table('photos') ->join('events', 'photos.event_id', '=', 'events.id') ->where('photos.id', $id) ->where('events.status', 'published') ->first(['photos.id', 'photos.event_id']); if (! $photo) { return ApiError::response( 'photo_not_found', 'Photo Not Found', 'Photo not found or event not public.', Response::HTTP_NOT_FOUND, ['photo_id' => $id] ); } // Idempotent like per device $exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists(); if ($exists) { $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); return response()->json(['liked' => true, 'likes_count' => $count]); } DB::beginTransaction(); try { DB::table('photo_likes')->insert([ 'photo_id' => $id, 'guest_name' => $deviceId, 'ip_address' => 'device', 'created_at' => now(), ]); DB::table('photos')->where('id', $id)->update([ 'likes_count' => DB::raw('likes_count + 1'), 'updated_at' => now(), ]); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Log::warning('like failed', ['error' => $e->getMessage()]); } $count = (int) DB::table('photos')->where('id', $id)->value('likes_count'); return response()->json(['liked' => true, 'likes_count' => $count]); } public function upload(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $eventModel = Event::with([ 'tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget', ])->findOrFail($eventId); $tenantModel = $eventModel->tenant; if (! $tenantModel) { return ApiError::response( 'event_not_found', 'Event not accessible', 'The selected event is no longer available.', Response::HTTP_NOT_FOUND, ['scope' => 'photos', 'event_id' => $eventId] ); } $violation = $this->packageLimitEvaluator->assessPhotoUpload($tenantModel, $eventId, $eventModel); if ($violation !== null) { return ApiError::response( $violation['code'], $violation['title'], $violation['message'], $violation['status'], $violation['meta'] ); } $eventPackage = $this->packageLimitEvaluator ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); $deviceId = (string) $request->header('X-Device-Id', 'anon'); $deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon'; // 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 ApiError::response( 'upload_device_limit', 'Device upload limit reached', 'This device cannot upload more photos for this event.', Response::HTTP_TOO_MANY_REQUESTS, [ 'scope' => 'photos', 'event_id' => $eventId, 'device_id' => $deviceId, 'limit' => 50, 'used' => $deviceCount, ] ); } $validated = $request->validate([ 'photo' => ['required', 'image', 'max:6144'], // 6 MB 'emotion_id' => ['nullable', 'integer'], 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], 'guest_name' => ['nullable', 'string', 'max:255'], ]); $file = $validated['photo']; $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel); $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file); $url = $this->resolveDiskUrl($disk, $path); // Generate thumbnail (JPEG) under photos/thumbs $baseName = pathinfo($path, PATHINFO_FILENAME); $thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg"; $thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82); $thumbUrl = $thumbPath ? $this->resolveDiskUrl($disk, $thumbPath) : $url; $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'is_featured' => 0, 'metadata' => null, 'created_at' => now(), 'updated_at' => now(), ]); $asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getClientMimeType(), 'size_bytes' => $file->getSize(), 'checksum' => hash_file('sha256', $file->getRealPath()), 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, ]); if ($thumbPath) { $this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, 'size_bytes' => Storage::disk($disk)->exists($thumbPath) ? Storage::disk($disk)->size($thumbPath) : null, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } DB::table('photos') ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); if ($eventPackage) { $previousUsed = (int) $eventPackage->used_photos; $eventPackage->increment('used_photos'); $eventPackage->refresh(); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1); } $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; } /** * Resolve emotion_id from validation data, supporting both direct ID and slug lookup */ private function resolveEmotionId(array $validated, int $eventId): int { // 1. Use explicit emotion_id if provided if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) { return (int) $validated['emotion_id']; } // 2. Resolve from emotion_slug if provided (format: 'emotion-{id}') if (isset($validated['emotion_slug']) && $validated['emotion_slug']) { $slug = $validated['emotion_slug']; if (str_starts_with($slug, 'emotion-')) { $id = (int) substr($slug, 8); // Remove 'emotion-' prefix if ($id > 0) { // Verify emotion exists and is available for this event $exists = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') ->where('emotions.id', $id) ->where('events.id', $eventId) ->exists(); if ($exists) { return $id; } } } } // 3. Fallback: Get first available emotion for this event type $defaultEmotion = DB::table('emotions') ->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id') ->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id') ->where('events.id', $eventId) ->orderBy('emotions.sort_order') ->value('emotions.id'); return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists) } public function achievements(Request $request, string $identifier) { $result = $this->resolvePublishedEvent($request, $identifier, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $eventId = $event->id; $locale = $request->query('locale', 'de'); $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count(); $uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name'); $tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); $likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count'); $summary = [ 'total_photos' => $totalPhotos, 'unique_guests' => $uniqueGuests, 'tasks_solved' => $tasksSolved, 'likes_total' => $likesTotal, ]; $guestNameParam = trim((string) $request->query('guest_name', '')); $deviceIdHeader = (string) $request->headers->get('X-Device-Id', ''); $deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120); $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId; $guestIdentifier = $candidate !== '' ? substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120) : null; $personal = null; if ($guestIdentifier) { $personalPhotos = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->count(); $personalTasks = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->whereNotNull('task_id') ->distinct('task_id') ->count('task_id'); $personalLikes = (int) DB::table('photos') ->where('event_id', $eventId) ->where('guest_name', $guestIdentifier) ->sum('likes_count'); $badgeBlueprints = [ ['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'], ['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'], ['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'], ['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'], ['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'], ]; $personalBadges = []; foreach ($badgeBlueprints as $badge) { $value = 0; if ($badge['metric'] === 'photos') { $value = $personalPhotos; } elseif ($badge['metric'] === 'tasks') { $value = $personalTasks; } else { $value = $personalLikes; } $personalBadges[] = [ 'id' => $badge['id'], 'title' => $badge['title'], 'description' => $badge['description'], 'earned' => $value >= $badge['target'], 'progress' => $value, 'target' => $badge['target'], ]; } $personal = [ 'guest_name' => $guestIdentifier, 'photos' => $personalPhotos, 'tasks' => $personalTasks, 'likes' => $personalLikes, 'badges' => $personalBadges, ]; } $topUploads = DB::table('photos') ->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes')) ->where('event_id', $eventId) ->groupBy('guest_name') ->orderByDesc('total') ->limit(5) ->get() ->map(fn ($row) => [ 'guest' => $row->guest_name, 'photos' => (int) $row->total, 'likes' => (int) $row->likes, ]) ->values(); $topLikes = DB::table('photos') ->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total')) ->where('event_id', $eventId) ->groupBy('guest_name') ->orderByDesc('likes') ->limit(5) ->get() ->map(fn ($row) => [ 'guest' => $row->guest_name, 'likes' => (int) $row->likes, 'photos' => (int) $row->total, ]) ->values(); $topPhotoRow = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) ->orderByDesc('photos.likes_count') ->orderByDesc('photos.created_at') ->select([ 'photos.id', 'photos.guest_name', 'photos.likes_count', 'photos.file_path', 'photos.thumbnail_path', 'photos.created_at', 'tasks.title as task_title', ]) ->first(); $topPhoto = $topPhotoRow ? [ 'photo_id' => (int) $topPhotoRow->id, 'guest' => $topPhotoRow->guest_name, 'likes' => (int) $topPhotoRow->likes_count, 'task' => $topPhotoRow->task_title, 'created_at' => $topPhotoRow->created_at, 'thumbnail' => $this->toPublicUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path), ] : null; $trendingEmotionRow = DB::table('photos') ->join('emotions', 'photos.emotion_id', '=', 'emotions.id') ->where('photos.event_id', $eventId) ->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total')) ->groupBy('emotions.id', 'emotions.name') ->orderByDesc('total') ->first(); $trendingEmotion = $trendingEmotionRow ? [ 'emotion_id' => (int) $trendingEmotionRow->id, 'name' => $this->getLocalized($trendingEmotionRow->name, $locale, $trendingEmotionRow->name), 'count' => (int) $trendingEmotionRow->total, ] : null; $timeline = DB::table('photos') ->where('event_id', $eventId) ->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests') ->groupBy('day') ->orderBy('day') ->limit(14) ->get() ->map(fn ($row) => [ 'date' => $row->day, 'photos' => (int) $row->photos, 'guests' => (int) $row->guests, ]) ->values(); $feed = DB::table('photos') ->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id') ->where('photos.event_id', $eventId) ->orderByDesc('photos.created_at') ->limit(12) ->get([ 'photos.id', 'photos.guest_name', 'photos.thumbnail_path', 'photos.file_path', 'photos.likes_count', 'photos.created_at', 'tasks.title as task_title', ]) ->map(fn ($row) => [ 'photo_id' => (int) $row->id, 'guest' => $row->guest_name, 'task' => $row->task_title, 'likes' => (int) $row->likes_count, 'created_at' => $row->created_at, 'thumbnail' => $this->toPublicUrl($row->thumbnail_path ?: $row->file_path), ]) ->values(); $payload = [ 'summary' => $summary, 'personal' => $personal, 'leaderboards' => [ 'uploads' => $topUploads, 'likes' => $topLikes, ], 'highlights' => [ 'top_photo' => $topPhoto, 'trending_emotion' => $trendingEmotion, 'timeline' => $timeline, ], 'feed' => $feed, ]; $etag = sha1(json_encode($payload)); $ifNoneMatch = $request->headers->get('If-None-Match'); if ($ifNoneMatch && $ifNoneMatch === $etag) { return response('', 304); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag); } private function resolveDiskUrl(string $disk, string $path): string { try { return Storage::disk($disk)->url($path); } catch (\Throwable $e) { Log::debug('Falling back to raw path for storage URL', [ 'disk' => $disk, 'path' => $path, 'error' => $e->getMessage(), ]); return $path; } } }