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 { $policy = $this->guestPolicy(); $failureLimit = max(1, (int) ($policy->join_token_failure_limit ?? config('join_tokens.failure_limit', 10))); $failureDecay = max(1, (int) ($policy->join_token_failure_decay_minutes ?? 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', $this->tokenErrorTitle('token_rate_limited'), __('api.join_tokens.invalid_attempts_message'), 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' => __('api.join_tokens.invalid_message'), 'token_expired' => __('api.join_tokens.expired_message'), 'token_revoked' => __('api.join_tokens.revoked_message'), 'token_rate_limited' => __('api.join_tokens.rate_limited_message'), default => __('api.join_tokens.default_message'), }; } private function tokenErrorTitle(string $code): string { return match ($code) { 'invalid_token' => __('api.join_tokens.invalid_title'), 'token_expired' => __('api.join_tokens.expired_title'), 'token_revoked' => __('api.join_tokens.revoked_title'), 'token_rate_limited' => __('api.join_tokens.rate_limited_title'), default => __('api.join_tokens.default_title'), }; } 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 { $policy = $this->guestPolicy(); $limit = (int) ($policy->join_token_access_limit ?? config('join_tokens.access_limit', 0)); if ($limit <= 0) { return null; } $decay = max(1, (int) ($policy->join_token_access_decay_minutes ?? 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 { $policy = $this->guestPolicy(); $limit = (int) ($policy->join_token_download_limit ?? config('join_tokens.download_limit', 0)); if ($limit <= 0) { return null; } $decay = max(1, (int) ($policy->join_token_download_decay_minutes ?? 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; } /** * @param array{tasks: array>, hash: string} $payload */ private function buildLocalizedTasksPayload(int $eventId, string $locale, ?string $eventDefaultLocale): array { $fallbacks = $this->localeFallbackChain($locale, $eventDefaultLocale); $rows = DB::table('tasks') ->leftJoin('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id') ->leftJoin('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id') ->leftJoin('event_task', function ($join) use ($eventId) { $join->on('tasks.id', '=', 'event_task.task_id') ->where('event_task.event_id', '=', $eventId); }) ->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id') ->where(function ($query) use ($eventId) { $query->where('event_task_collection.event_id', $eventId) ->orWhereNotNull('event_task.event_id'); }) ->select([ 'tasks.id', 'tasks.title', 'tasks.description', 'tasks.example_text', 'tasks.emotion_id', 'tasks.sort_order', 'event_task.sort_order as direct_sort_order', 'event_task_collection.sort_order as collection_sort_order', 'emotions.name as emotion_name', 'emotions.id as emotion_lookup_id', ]) ->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)') ->orderBy('tasks.sort_order') ->distinct('tasks.id') ->get(); $tasks = $rows->map(function ($row) use ($fallbacks) { $emotion = null; if ($row->emotion_id) { $emotionName = $this->firstLocalizedValue($row->emotion_name, $fallbacks, 'Unbekannte Emotion'); if ($emotionName !== '') { $emotionId = (int) ($row->emotion_lookup_id ?? $row->emotion_id); $emotion = [ 'slug' => 'emotion-'.$emotionId, 'name' => $emotionName, ]; } } return [ 'id' => (int) $row->id, 'title' => $this->firstLocalizedValue($row->title, $fallbacks, 'Unbenannte Aufgabe'), 'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''), 'instructions' => $this->firstLocalizedValue($row->example_text, $fallbacks, ''), 'duration' => 3, 'is_completed' => false, 'emotion' => $emotion, ]; })->values()->all(); return [ 'tasks' => $tasks, 'hash' => sha1(json_encode($tasks)), ]; } private function resolveGuestLocale(Request $request, object $event): array { $supported = $this->eventTasksCache->supportedLocales(); $queryLocale = $this->normalizeLocale($request->query('locale', $request->query('lang')), $supported); if ($queryLocale) { return [$queryLocale, 'query']; } $headerLocale = $this->normalizeLocale($request->header('X-Locale'), $supported); if ($headerLocale) { return [$headerLocale, 'header']; } $acceptedLocale = $this->normalizeLocale($request->getPreferredLanguage($supported), $supported); if ($acceptedLocale) { return [$acceptedLocale, 'accept-language']; } $eventLocale = $this->normalizeLocale($event->default_locale ?? null, $supported); if ($eventLocale) { return [$eventLocale, 'event']; } $fallbackLocale = $this->normalizeLocale(config('app.fallback_locale'), $supported); if ($fallbackLocale) { return [$fallbackLocale, 'fallback']; } return [$supported[0] ?? 'de', 'fallback']; } private function localeFallbackChain(string $preferred, ?string $eventDefault): array { $chain = array_filter([ $preferred, $this->normalizeLocale($eventDefault), $this->normalizeLocale(config('app.fallback_locale')), 'de', 'en', ]); return array_values(array_unique($chain)); } private function normalizeLocale(?string $value, ?array $allowed = null): ?string { if ($value === null) { return null; } $normalized = Str::of($value) ->lower() ->replace('_', '-') ->before('-') ->trim() ->value(); if ($normalized === '') { return null; } if ($allowed !== null && ! in_array($normalized, $allowed, true)) { return null; } return $normalized; } private function decodeTranslations(mixed $value): array { if (is_array($value)) { return $value; } if (is_string($value) && $value !== '') { $decoded = json_decode($value, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { return $decoded; } } return []; } private function firstLocalizedValue(mixed $value, array $locales, string $default = ''): string { $translations = $this->decodeTranslations($value); foreach ($locales as $locale) { $candidate = $translations[$locale] ?? null; if (is_string($candidate) && trim($candidate) !== '') { return $candidate; } } if (is_string($value) && trim($value) !== '') { return $value; } if (! empty($translations)) { $first = reset($translations); if (is_string($first) && trim($first) !== '') { return $first; } } return $default; } private function determineGuestIdentifier(Request $request): ?string { $guestNameParam = trim((string) $request->query('guest_name', '')); $deviceIdHeader = (string) $request->headers->get('X-Device-Id', ''); $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceIdHeader; $normalized = $this->normalizeGuestIdentifier($candidate); if ($normalized === '') { return null; } return $normalized; } private function normalizeGuestIdentifier(string $value): string { $cleaned = preg_replace('/[^\p{L}\p{N}\s_\-]/u', '', $value) ?? ''; $trimmed = trim(mb_substr($cleaned, 0, 120)); return $trimmed; } /** * @return array */ private function normalizeSettings(array|string|null $settings): array { if (is_array($settings)) { return $settings; } if (is_string($settings)) { $decoded = json_decode($settings, true); if (is_array($decoded)) { return $decoded; } } return []; } private function buildDeterministicSeed(?string $identifier): int { if ($identifier === null || trim($identifier) === '') { return random_int(1, PHP_INT_MAX); } $hash = substr(sha1($identifier), 0, 8); $seed = hexdec($hash); if (! is_numeric($seed) || $seed <= 0) { $seed = abs(crc32($identifier)); } return max(1, (int) $seed); } private function buildAchievementsPayload(int $eventId, string $token, ?string $guestIdentifier, array $fallbacks): array { $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, ]; $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 = match ($badge['metric']) { 'photos' => $personalPhotos, 'tasks' => $personalTasks, default => $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) ->where('photos.status', 'approved') ->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' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null, 'created_at' => $topPhotoRow->created_at, 'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $topPhotoRow->id, 'thumbnail') ?? $this->resolveSignedFallbackUrl($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->firstLocalizedValue($trendingEmotionRow->name, $fallbacks, 'Emotion'), '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) ->where('photos.status', 'approved') ->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' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null, 'likes' => (int) $row->likes_count, 'created_at' => $row->created_at, 'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $row->id, 'thumbnail') ?? $this->resolveSignedFallbackUrl($row->thumbnail_path ?: $row->file_path), ]) ->values(); return [ 'summary' => $summary, 'personal' => $personal, 'leaderboards' => [ 'uploads' => $topUploads, 'likes' => $topLikes, ], 'highlights' => [ 'top_photo' => $topPhoto, 'trending_emotion' => $trendingEmotion, 'timeline' => $timeline, ], 'feed' => $feed, ]; } private function resolveSignedFallbackUrl(?string $path): ?string { if (! $path) { return null; } // If already signed or absolute, return as-is if (str_contains($path, 'signature=') || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } return null; } 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 determineBrandingAllowed(Event $event): bool { return WatermarkConfigResolver::determineBrandingAllowed($event); } private function determineWatermarkPolicy(Event $event): string { return WatermarkConfigResolver::determinePolicy($event); } private function resolveWatermarkConfig(Event $event): array { return WatermarkConfigResolver::resolve($event); } private function buildGalleryBranding(Event $event): array { return $this->resolveBrandingPayload($event); } private function normalizeHexColor(?string $value, string $fallback): string { if (is_string($value)) { $trimmed = trim($value); if (preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $trimmed) === 1) { return $trimmed; } } return $fallback; } /** * @param array $sources */ private function firstStringFromSources(array $sources, array $keys): ?string { foreach ($sources as $source) { foreach ($keys as $key) { $value = Arr::get($source ?? [], $key); if (is_string($value) && trim($value) !== '') { return trim($value); } } } return null; } /** * @param array $sources */ private function firstNumberFromSources(array $sources, array $keys): ?float { foreach ($sources as $source) { foreach ($keys as $key) { $value = Arr::get($source ?? [], $key); if (is_numeric($value)) { return (float) $value; } } } return null; } /** * @return array{ * primary_color: string, * secondary_color: string, * background_color: string, * surface_color: string, * font_family: ?string, * heading_font: ?string, * body_font: ?string, * font_size: string, * logo_url: ?string, * logo_mode: string, * logo_value: ?string, * logo_position: string, * logo_size: string, * button_style: string, * button_radius: int, * button_primary_color: string, * button_secondary_color: string, * link_color: string, * mode: string, * use_default_branding: bool, * palette: array{primary:string, secondary:string, background:string, surface:string}, * typography: array{heading:?string, body:?string, size:string}, * logo: array{mode:string, value:?string, position:string, size:string}, * buttons: array{style:string, radius:int, primary:string, secondary:string, link_color:string}, * icon: ?string * } */ private function resolveBrandingPayload(Event $event): array { $defaults = [ 'primary' => '#FF5A5F', 'secondary' => '#FFF8F5', 'background' => '#FFF8F5', 'surface' => '#FFF8F5', 'font' => null, 'size' => 'm', 'logo_position' => 'left', 'logo_size' => 'm', 'button_style' => 'filled', 'button_radius' => 12, 'mode' => 'auto', ]; $event->loadMissing('eventPackage.package', 'tenant', 'eventType'); $brandingAllowed = $this->determineBrandingAllowed($event); $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : []; $tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : []; $useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false)); $sources = $brandingAllowed ? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding]) : [[]]; $primary = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']), $defaults['primary'] ); $secondary = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['palette.secondary', 'secondary_color']), $defaults['secondary'] ); $background = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['palette.background', 'background_color']), $defaults['background'] ); $surface = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['palette.surface', 'surface_color']), $background ?: $defaults['surface'] ); $headingFont = $this->firstStringFromSources($sources, ['typography.heading', 'heading_font', 'font_family']); $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']); $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size']; $fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size']; $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']); if (! in_array($logoMode, ['emoticon', 'upload'], true)) { $logoMode = null; } $logoPosition = $this->firstStringFromSources($sources, ['logo.position', 'logo_position']) ?? $defaults['logo_position']; if (! in_array($logoPosition, ['left', 'right', 'center'], true)) { $logoPosition = $defaults['logo_position']; } $logoSize = $this->firstStringFromSources($sources, ['logo.size', 'logo_size']) ?? $defaults['logo_size']; if (! in_array($logoSize, ['s', 'm', 'l'], true)) { $logoSize = $defaults['logo_size']; } $logoRawValue = $this->firstStringFromSources($sources, ['logo.value', 'logo_url', 'icon']) ?? ($event->eventType?->icon ?? null); if (! $logoMode) { $logoMode = $logoRawValue && (preg_match('/^https?:\/\//', $logoRawValue) === 1 || str_starts_with($logoRawValue, '/storage/') || str_starts_with($logoRawValue, 'storage/')) ? 'upload' : 'emoticon'; } $logoValue = $logoMode === 'upload' ? $this->makeSignedBrandingUrl($logoRawValue) : $logoRawValue; $buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style']; if (! in_array($buttonStyle, ['filled', 'outline'], true)) { $buttonStyle = $defaults['button_style']; } $buttonRadius = (int) ($this->firstNumberFromSources($sources, ['buttons.radius', 'button_radius']) ?? $defaults['button_radius']); $buttonRadius = $buttonRadius > 0 ? $buttonRadius : $defaults['button_radius']; $buttonPrimary = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['buttons.primary', 'button_primary_color']), $primary ); $buttonSecondary = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['buttons.secondary', 'button_secondary_color']), $secondary ); $linkColor = $this->normalizeHexColor( $this->firstStringFromSources($sources, ['buttons.link_color', 'link_color']), $secondary ); $mode = $this->firstStringFromSources($sources, ['mode']) ?? $defaults['mode']; if (! in_array($mode, ['light', 'dark', 'auto'], true)) { $mode = $defaults['mode']; } return [ 'primary_color' => $primary, 'secondary_color' => $secondary, 'background_color' => $background, 'surface_color' => $surface, 'font_family' => $bodyFont, 'heading_font' => $headingFont, 'body_font' => $bodyFont, 'font_size' => $fontSize, 'logo_url' => $logoMode === 'upload' ? $logoValue : null, 'logo_mode' => $logoMode, 'logo_value' => $logoValue, 'logo_position' => $logoPosition, 'logo_size' => $logoSize, 'button_style' => $buttonStyle, 'button_radius' => $buttonRadius, 'button_primary_color' => $buttonPrimary, 'button_secondary_color' => $buttonSecondary, 'link_color' => $linkColor, 'mode' => $mode, 'use_default_branding' => $useDefault, 'palette' => [ 'primary' => $primary, 'secondary' => $secondary, 'background' => $background, 'surface' => $surface, ], 'typography' => [ 'heading' => $headingFont, 'body' => $bodyFont, 'size' => $fontSize, ], 'logo' => [ 'mode' => $logoMode, 'value' => $logoValue, 'position' => $logoPosition, 'size' => $logoSize, ], 'buttons' => [ 'style' => $buttonStyle, 'radius' => $buttonRadius, 'primary' => $buttonPrimary, 'secondary' => $buttonSecondary, 'link_color' => $linkColor, ], 'icon' => $logoMode === 'emoticon' ? $logoValue : ($event->eventType?->icon ?? null), ]; } 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 makeSignedGalleryAssetUrlForId(string $token, int $photoId, 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' => $photoId, 'variant' => $variant, ] ); } private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string { return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant); } private function makeSignedPendingAssetUrl(string $token, int $photoId, string $variant, string $deviceId): ?string { if (! in_array($variant, ['thumbnail', 'full'], true)) { return null; } return URL::temporarySignedRoute( 'api.v1.events.pending-photos.asset', now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), [ 'token' => $token, 'photo' => $photoId, 'variant' => $variant, 'device_id' => $deviceId, ] ); } private function makeSignedBrandingUrl(?string $path): ?string { if (! $path) { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) { return $path; } $normalized = ltrim($path, '/'); if (str_starts_with($normalized, 'storage/')) { $normalized = substr($normalized, strlen('storage/')); } if (! $this->isAllowedBrandingPath($normalized)) { return null; } return URL::temporarySignedRoute( 'api.v1.branding.asset', now()->addSeconds(self::BRANDING_SIGNED_TTL_SECONDS), ['path' => $normalized] ); } public function brandingAsset(Request $request, string $path) { $cleanPath = ltrim($path, '/'); if ($cleanPath === '' || str_contains($cleanPath, '..') || ! $this->isAllowedBrandingPath($cleanPath)) { return ApiError::response( 'branding_not_found', 'Branding Asset Not Found', 'The requested branding asset could not be found.', Response::HTTP_NOT_FOUND, ['path' => $path] ); } $diskName = 'public'; try { $storage = Storage::disk($diskName); } catch (\Throwable $e) { Log::warning('Branding asset disk unavailable', [ 'path' => $cleanPath, 'disk' => $diskName, 'error' => $e->getMessage(), ]); return ApiError::response( 'branding_not_found', 'Branding Asset Not Found', 'The requested branding asset could not be found.', Response::HTTP_NOT_FOUND, ['path' => $path] ); } if (! $storage->exists($cleanPath)) { return ApiError::response( 'branding_not_found', 'Branding Asset Not Found', 'The requested branding asset could not be found.', Response::HTTP_NOT_FOUND, ['path' => $path] ); } try { $stream = $storage->readStream($cleanPath); if (! $stream) { throw new \RuntimeException('Unable to read branding asset stream.'); } $mime = $storage->mimeType($cleanPath) ?: 'application/octet-stream'; $size = null; try { $size = $storage->size($cleanPath); } catch (\Throwable $e) { $size = null; } $headers = [ 'Content-Type' => $mime, 'Cache-Control' => 'private, max-age='.self::BRANDING_SIGNED_TTL_SECONDS, ]; if ($size) { $headers['Content-Length'] = $size; } return response()->stream(function () use ($stream) { fpassthru($stream); fclose($stream); }, 200, $headers); } catch (\Throwable $e) { Log::warning('Branding asset stream error', [ 'path' => $cleanPath, 'disk' => $diskName, 'error' => $e->getMessage(), ]); return ApiError::response( 'branding_not_found', 'Branding Asset Not Found', 'The requested branding asset could not be loaded.', Response::HTTP_NOT_FOUND, ['path' => $path] ); } } private function isAllowedBrandingPath(string $path): bool { if ($path === '' || str_contains($path, '..')) { return false; } $allowedPrefixes = [ 'branding/', 'events/', 'tenant-branding/', ]; foreach ($allowedPrefixes as $prefix) { if (str_starts_with($path, $prefix)) { return true; } } return false; } 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, ] ); } private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string { return URL::temporarySignedRoute( 'api.v1.photo-shares.asset', now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), [ 'slug' => $shareLink->slug, 'variant' => $variant, ], absolute: false ); } 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; $settings = is_array($event->settings) ? $event->settings : []; $policy = $this->guestPolicy(); 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(), 'guest_downloads_enabled' => (bool) ($settings['guest_downloads_enabled'] ?? $policy->guest_downloads_enabled), 'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? $policy->guest_sharing_enabled), ], '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 pendingUploads(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $deviceId = $this->resolveDeviceIdentifier($request); if ($deviceId === 'anonymous') { return response()->json([ 'data' => [], 'meta' => ['total_count' => 0], ])->header('Cache-Control', 'no-store'); } $limit = (int) $request->query('limit', 12); $limit = max(1, min($limit, 30)); $baseQuery = Photo::query() ->where('event_id', $event->id) ->where('status', 'pending') ->where('created_by_device_id', $deviceId); $totalCount = (clone $baseQuery)->count(); $photos = $baseQuery ->orderByDesc('created_at') ->limit($limit) ->get(['id', 'created_at', 'status']); $data = $photos->map(fn (Photo $photo) => [ 'id' => $photo->id, 'status' => $photo->status, 'created_at' => $photo->created_at?->toIso8601String(), 'thumbnail_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'thumbnail', $deviceId), 'full_url' => $this->makeSignedPendingAssetUrl($token, (int) $photo->id, 'full', $deviceId), ])->all(); return response()->json([ 'data' => $data, 'meta' => ['total_count' => $totalCount], ])->header('Cache-Control', 'no-store'); } public function createShareLink(Request $request, string $token, Photo $photo) { $resolved = $this->resolvePublishedEvent($request, $token, ['id']); if ($resolved instanceof JsonResponse) { return $resolved; } /** @var array{0: object{id:int}} $resolved */ [$eventRecord] = $resolved; if ((int) $photo->event_id !== (int) $eventRecord->id) { return ApiError::response( 'photo_not_shareable', 'Photo Not Shareable', 'The selected photo cannot be shared at this time.', Response::HTTP_NOT_FOUND, [ 'photo_id' => $photo->id, 'event_id' => $eventRecord->id, ] ); } $deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', ''))); $policy = $this->guestPolicy(); $ttlHours = max(1, (int) ($policy->share_link_ttl_hours ?? config('share-links.ttl_hours', 48))); $existing = PhotoShareLink::query() ->where('photo_id', $photo->id) ->when($deviceId !== '', fn ($query) => $query->where('created_by_device_id', $deviceId)) ->where('expires_at', '>', now()) ->latest('id') ->first(); if ($existing && ! $existing->isExpired()) { $shareLink = $existing; } else { $shareLink = PhotoShareLink::create([ 'photo_id' => $photo->id, 'slug' => PhotoShareLink::generateSlug(), 'expires_at' => now()->addHours($ttlHours), 'created_by_device_id' => $deviceId ?: null, 'created_ip' => $request->ip(), ]); } return response()->json([ 'slug' => $shareLink->slug, 'expires_at' => $shareLink->expires_at?->toIso8601String(), 'url' => url("/share/{$shareLink->slug}"), ])->header('Cache-Control', 'no-store'); } public function shareLink(Request $request, string $slug) { $shareLink = PhotoShareLink::with(['photo.event', 'photo.emotion', 'photo.task']) ->where('slug', $slug) ->first(); if (! $shareLink || $shareLink->isExpired()) { return ApiError::response( 'share_link_expired', 'Link Expired', 'This shared photo link is no longer available.', Response::HTTP_GONE, ['slug' => $slug] ); } $shareLink->forceFill(['last_accessed_at' => now()])->save(); $photo = $shareLink->photo; $event = $photo->event; if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) { return ApiError::response( 'photo_not_shareable', 'Photo Not Shareable', 'The shared photo is no longer available.', Response::HTTP_NOT_FOUND, ['slug' => $slug] ); } $taskTitle = null; if ($photo->task) { $taskTitle = $this->translateLocalized($photo->task->title, app()->getLocale(), ''); if ($taskTitle === '') { $taskTitle = null; } } $emotionName = null; if ($photo->emotion) { $emotionName = $this->translateLocalized($photo->emotion->name, app()->getLocale(), ''); if ($emotionName === '') { $emotionName = is_string($photo->emotion->name) ? $photo->emotion->name : null; } } $photoResource = [ 'id' => $photo->id, 'title' => $taskTitle, 'emotion' => $photo->emotion ? [ 'name' => $emotionName, 'emoji' => $photo->emotion->emoji, ] : null, 'likes_count' => $photo->likes()->count(), 'created_at' => $photo->created_at?->toIso8601String(), 'image_urls' => [ 'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'), 'full' => $this->makeShareAssetUrl($shareLink, 'full'), ], ]; return response()->json([ 'slug' => $shareLink->slug, 'expires_at' => $shareLink->expires_at?->toIso8601String(), 'photo' => $photoResource, 'event' => $event ? [ 'id' => $event->id, 'name' => $event->name, 'city' => $event->city, ] : null, ])->header('Cache-Control', 'no-store'); } public function shareLinkAsset(Request $request, string $slug, string $variant) { if (! in_array($variant, ['thumbnail', 'full'], true)) { return ApiError::response( 'invalid_variant', 'Invalid Variant', 'The requested asset variant is not supported.', Response::HTTP_BAD_REQUEST ); } $shareLink = PhotoShareLink::with(['photo.mediaAsset', 'photo.event.tenant']) ->where('slug', $slug) ->first(); if (! $shareLink || $shareLink->isExpired()) { return ApiError::response( 'share_link_expired', 'Link Expired', 'This shared photo link is no longer available.', Response::HTTP_GONE, ['slug' => $slug] ); } $photo = $shareLink->photo; $event = $photo->event; if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) { return ApiError::response( 'photo_not_shareable', 'Photo Not Shareable', 'The shared photo is no longer available.', Response::HTTP_NOT_FOUND, ['slug' => $slug] ); } $variantPreference = $variant === 'thumbnail' ? ['thumbnail', 'original'] : ['original']; $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false); return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline'); } 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 pendingPhotoAsset(Request $request, string $token, int $photo, string $variant) { $resolved = $this->resolveGalleryEvent($request, $token); if ($resolved instanceof JsonResponse) { return $resolved; } [$event] = $resolved; $deviceId = $this->normalizeGuestIdentifier((string) $request->query('device_id', '')); if ($deviceId === '') { return ApiError::response( 'pending_photo_forbidden', 'Pending Photo Access Denied', 'The pending photo cannot be accessed.', Response::HTTP_FORBIDDEN ); } $record = Photo::with('mediaAsset') ->where('id', $photo) ->where('event_id', $event->id) ->where('status', 'pending') ->where('created_by_device_id', $deviceId) ->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); $settings = $this->normalizeSettings($event->settings ?? []); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; $liveShowSettings = Arr::get($settings, 'live_show', []); $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : []; $event->loadMissing('photoboothSetting'); $policy = $this->guestPolicy(); if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); } $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false); 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, 'demo_read_only' => $demoReadOnly, 'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled), 'branding' => $branding, 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility), 'live_show' => [ 'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual', ], 'engagement_mode' => $engagementMode, ])->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); $watermarkPolicy = $this->determineWatermarkPolicy($event); $brandingAllowed = $this->determineBrandingAllowed($event); $watermark = $this->resolveWatermarkConfig($event); 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, 'watermark_policy' => $watermarkPolicy, 'branding_allowed' => $brandingAllowed, ], 'used_photos' => (int) $eventPackage->used_photos, 'used_guests' => (int) $eventPackage->used_guests, 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), 'limits' => $summary, 'watermark' => $watermark, ])->header('Cache-Control', 'no-store'); } private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition) { $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false); foreach ($variantPreference as $variant) { [$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals); 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(), ]); } } } 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, bool $preferOriginals = false): array { if ($variant === 'thumbnail') { $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first(); $watermarked = $preferOriginals ? null : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_thumbnail')->first(); $disk = $asset?->disk ?? $record->mediaAsset?->disk; $path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path); $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg'; } else { $watermarked = $preferOriginals ? null : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked')->first(); $asset = $record->mediaAsset ?? $watermarked ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); $disk = $asset?->disk ?? $record->mediaAsset?->disk; $path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null); $mime = $watermarked?->mime_type ?? $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 notifications(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id', 'tenant_id']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $guestIdentifier = $this->resolveNotificationIdentifier($request); $limit = max(1, min(50, (int) $request->integer('limit', 35))); $statusFilter = $request->string('status')->lower()->value(); $scopeFilter = $request->string('scope')->lower()->value(); if (! Schema::hasTable('guest_notifications')) { return $this->emptyNotificationsResponse($request, $event->id, 'disabled'); } $baseQuery = GuestNotification::query() ->where('event_id', $event->id) ->active() ->notExpired() ->visibleToGuest($guestIdentifier); if ($statusFilter === 'unread') { $baseQuery->where(function ($query) use ($guestIdentifier) { $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier)) ->orWhereHas('receipts', fn ($receipt) => $receipt ->where('guest_identifier', $guestIdentifier) ->where('status', GuestNotificationDeliveryStatus::NEW->value)); }); } elseif ($statusFilter === 'read') { $baseQuery->whereHas('receipts', fn ($receipt) => $receipt ->where('guest_identifier', $guestIdentifier) ->where('status', GuestNotificationDeliveryStatus::READ->value)); } elseif ($statusFilter === 'dismissed') { $baseQuery->whereHas('receipts', fn ($receipt) => $receipt ->where('guest_identifier', $guestIdentifier) ->where('status', GuestNotificationDeliveryStatus::DISMISSED->value)); } if ($scopeFilter === 'uploads') { $baseQuery->whereIn('type', [GuestNotificationType::UPLOAD_ALERT->value, GuestNotificationType::PHOTO_ACTIVITY->value]); } elseif ($scopeFilter === 'tips') { $baseQuery->whereIn('type', [GuestNotificationType::SUPPORT_TIP->value, GuestNotificationType::ACHIEVEMENT_MAJOR->value]); } elseif ($scopeFilter === 'general') { $baseQuery->whereIn('type', [GuestNotificationType::BROADCAST->value, GuestNotificationType::FEEDBACK_REQUEST->value]); } $notifications = (clone $baseQuery) ->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)]) ->orderByDesc('priority') ->orderByDesc('id') ->limit($limit) ->get(); $unreadCount = (clone $baseQuery) ->where(function ($query) use ($guestIdentifier) { $query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier)) ->orWhereHas('receipts', fn ($receipt) => $receipt ->where('guest_identifier', $guestIdentifier) ->where('status', GuestNotificationDeliveryStatus::NEW->value)); }) ->count(); $data = $notifications->map(fn (GuestNotification $notification) => $this->formatGuestNotification($notification, $guestIdentifier)); $etag = sha1(json_encode([ $event->id, $guestIdentifier, $unreadCount, $notifications->first()?->updated_at?->toAtomString(), ])); $clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags()); if (in_array($etag, $clientEtags, true)) { return response('', 304) ->header('ETag', $etag) ->header('Cache-Control', 'no-store') ->header('Vary', 'X-Device-Id, Accept-Language'); } return response()->json([ 'data' => $data, 'meta' => [ 'unread_count' => $unreadCount, 'poll_after_seconds' => 90, ], ])->header('ETag', $etag) ->header('Cache-Control', 'no-store') ->header('Vary', 'X-Device-Id, Accept-Language'); } private function emptyNotificationsResponse(Request $request, int $eventId, string $reason = 'empty'): JsonResponse { $etag = sha1(sprintf('event:%d:guest_notifications:%s', $eventId, $reason)); $clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags()); if (in_array($etag, $clientEtags, true)) { return response()->json([], Response::HTTP_NOT_MODIFIED) ->header('ETag', $etag) ->header('Cache-Control', 'no-store') ->header('Vary', 'X-Device-Id, Accept-Language'); } return response()->json([ 'data' => [], 'meta' => [ 'unread_count' => 0, 'poll_after_seconds' => 120, 'reason' => $reason, ], ])->header('ETag', $etag) ->header('Cache-Control', 'no-store') ->header('Vary', 'X-Device-Id, Accept-Language'); } public function registerPushSubscription(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$eventRecord] = $result; $validated = $request->validate([ 'endpoint' => ['required', 'url', 'max:500'], 'keys.p256dh' => ['required', 'string', 'max:255'], 'keys.auth' => ['required', 'string', 'max:255'], 'expiration_time' => ['nullable'], 'content_encoding' => ['nullable', 'string', 'max:32'], ]); $event = Event::findOrFail($eventRecord->id); $guestIdentifier = $this->resolveNotificationIdentifier($request); $deviceId = $this->resolveDeviceIdentifier($request); $payload = [ 'endpoint' => $validated['endpoint'], 'keys' => [ 'p256dh' => $validated['keys']['p256dh'], 'auth' => $validated['keys']['auth'], ], 'expiration_time' => $validated['expiration_time'] ?? null, 'content_encoding' => $validated['content_encoding'] ?? null, 'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'), 'user_agent' => (string) $request->userAgent(), ]; $subscription = $this->pushSubscriptions->register($event, $guestIdentifier, $deviceId, $payload); return response()->json([ 'id' => $subscription->id, 'status' => $subscription->status, ], Response::HTTP_CREATED); } public function destroyPushSubscription(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$eventRecord] = $result; $validated = $request->validate([ 'endpoint' => ['required', 'url', 'max:500'], ]); $event = Event::findOrFail($eventRecord->id); $revoked = $this->pushSubscriptions->revoke($event, $validated['endpoint']); return response()->json([ 'status' => $revoked ? 'revoked' : 'not_found', ]); } public function markNotificationRead(Request $request, string $token, GuestNotification $notification) { return $this->handleNotificationAction($request, $token, $notification, 'read'); } public function dismissNotification(Request $request, string $token, GuestNotification $notification) { return $this->handleNotificationAction($request, $token, $notification, 'dismiss'); } private function handleNotificationAction(Request $request, string $token, GuestNotification $notification, string $action) { $result = $this->resolvePublishedEvent($request, $token, ['id']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; if ((int) $notification->event_id !== (int) $event->id) { return ApiError::response( 'notification_not_found', 'Notification not found', 'Diese Benachrichtigung gehört nicht zu diesem Event.', Response::HTTP_NOT_FOUND ); } $guestIdentifier = $this->resolveNotificationIdentifier($request); if (! $this->notificationVisibleToGuest($notification, $guestIdentifier)) { return ApiError::response( 'notification_forbidden', 'Notification unavailable', 'Diese Nachricht steht nicht zur Verfügung.', Response::HTTP_FORBIDDEN ); } $receipt = $action === 'read' ? $this->guestNotificationService->markAsRead($notification, $guestIdentifier) : $this->guestNotificationService->dismiss($notification, $guestIdentifier); return response()->json([ 'id' => $notification->id, 'status' => $receipt->status->value, 'read_at' => $receipt->read_at?->toAtomString(), 'dismissed_at' => $receipt->dismissed_at?->toAtomString(), ]); } private function notificationVisibleToGuest(GuestNotification $notification, string $guestIdentifier): bool { if ($notification->status !== GuestNotificationState::ACTIVE) { return false; } if ($notification->hasExpired()) { return false; } if ($notification->audience_scope === GuestNotificationAudience::ALL) { return true; } return $notification->audience_scope === GuestNotificationAudience::GUEST && $notification->target_identifier === $guestIdentifier; } private function formatGuestNotification(GuestNotification $notification, string $guestIdentifier): array { $receipt = $notification->receipts->firstWhere('guest_identifier', $guestIdentifier); $status = $receipt?->status ?? GuestNotificationDeliveryStatus::NEW; $payload = $notification->payload ?? []; $cta = null; if (is_array($payload) && isset($payload['cta']) && is_array($payload['cta'])) { $ctaPayload = $payload['cta']; if (! empty($ctaPayload['label']) && ! empty($ctaPayload['href'])) { $cta = [ 'label' => (string) $ctaPayload['label'], 'href' => (string) $ctaPayload['href'], ]; } } return [ 'id' => (int) $notification->id, 'type' => $notification->type->value, 'title' => $notification->title, 'body' => $notification->body, 'status' => $status->value, 'created_at' => $notification->created_at?->toAtomString(), 'read_at' => $receipt?->read_at?->toAtomString(), 'dismissed_at' => $receipt?->dismissed_at?->toAtomString(), 'cta' => $cta, 'payload' => $payload, ]; } private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token, int $limit): void { $title = 'Upload-Limit erreicht'; $body = sprintf( 'Du hast bereits %d Fotos hochgeladen. Wir speichern deine Warteschlange – bitte lösche zuerst alte Uploads.', $limit ); $this->guestNotificationService->createNotification( $event, GuestNotificationType::UPLOAD_ALERT, $title, $body, [ 'audience_scope' => GuestNotificationAudience::GUEST, 'target_identifier' => $guestIdentifier, 'payload' => [ 'cta' => [ 'label' => 'Warteschlange öffnen', 'href' => sprintf('/e/%s/queue', urlencode($token)), ], ], 'expires_at' => now()->addHours(6), ] ); } private function notifyPhotoLimitReached(Event $event): void { $title = 'Uploads pausiert – Event-Limit erreicht'; if ($this->eventHasActiveNotification($event, GuestNotificationType::UPLOAD_ALERT, $title)) { return; } $body = 'Es können aktuell keine neuen Fotos hochgeladen werden. Wir informieren den Host – bleib kurz dran!'; $this->guestNotificationService->createNotification( $event, GuestNotificationType::UPLOAD_ALERT, $title, $body, [ 'audience_scope' => GuestNotificationAudience::ALL, 'expires_at' => now()->addHours(3), ] ); } private function guestPolicy(): GuestPolicySetting { return $this->guestPolicy ??= GuestPolicySetting::current(); } private function eventHasActiveNotification(Event $event, GuestNotificationType $type, string $title): bool { return GuestNotification::query() ->where('event_id', $event->id) ->where('type', $type->value) ->where('title', $title) ->where('status', GuestNotificationState::ACTIVE->value) ->exists(); } private function resolveNotificationIdentifier(Request $request): string { $identifier = $this->determineGuestIdentifier($request); if ($identifier) { return $identifier; } return $this->resolveDeviceIdentifier($request); } private function resolveDeviceIdentifier(Request $request): string { $deviceId = (string) $request->headers->get('X-Device-Id', ''); $normalized = $this->normalizeGuestIdentifier($deviceId); return $normalized !== '' ? $normalized : 'anonymous'; } public function stats(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id', 'settings']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $eventId = $event->id; $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); $settings = $this->normalizeSettings($event->settings ?? null); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; // 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 = $engagementMode === 'photo_only' ? 0 : 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, 'engagement_mode' => $engagementMode, ]; $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', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $eventId = (int) $event->id; [$locale] = $this->resolveGuestLocale($request, $event); $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); $cacheKey = sprintf('event:%d:emotions:%s', $eventId, $locale); $cached = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($eventId, $fallbacks) { $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(); $data = $rows->map(function ($row) use ($fallbacks) { return [ 'id' => (int) $row->id, 'slug' => 'emotion-'.$row->id, 'name' => $this->firstLocalizedValue($row->name, $fallbacks, 'Emotion'), 'emoji' => $row->emoji, 'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''), ]; })->values()->all(); return [ 'data' => $data, 'hash' => sha1(json_encode($data)), ]; }); $payload = $cached['data']; $etag = $cached['hash']; $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag) ->header('Vary', 'Accept-Language, X-Locale') ->header('X-Content-Locale', $locale); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=300') ->header('ETag', $etag) ->header('Vary', 'Accept-Language, X-Locale') ->header('X-Content-Locale', $locale); } public function tasks(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale', 'settings']); if ($result instanceof JsonResponse) { return $result; } [$event, $joinToken] = $result; $settings = $this->normalizeSettings($event->settings ?? null); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; [$resolvedLocale] = $this->resolveGuestLocale($request, $event); $page = max(1, (int) $request->query('page', 1)); $perPage = max(1, min(100, (int) $request->query('per_page', 20))); if ($engagementMode === 'photo_only') { $payload = [ 'data' => [], 'meta' => [ 'total' => 0, 'per_page' => $perPage, 'current_page' => $page, 'last_page' => 1, 'has_more' => false, 'seed' => null, ], 'engagement_mode' => $engagementMode, ]; return response()->json($payload) ->header('Cache-Control', 'public, max-age=120') ->header('Vary', 'Accept-Language, X-Locale') ->header('X-Content-Locale', $resolvedLocale) ->header('X-Engagement-Mode', $engagementMode); } $cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) { return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null); }); $tasks = $cached['tasks']; $baseHash = $cached['hash'] ?? sha1(json_encode($tasks)); // Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed. $seedParam = $request->query('seed'); $guestIdentifier = $this->determineGuestIdentifier($request); $seedValue = is_numeric($seedParam) ? (int) $seedParam : $this->buildDeterministicSeed(($guestIdentifier ? $guestIdentifier.'|' : '').(string) $event->id); $randomizer = new \Random\Randomizer(new \Random\Engine\Mt19937($seedValue)); $shuffled = $randomizer->shuffleArray($tasks); $total = count($shuffled); $offset = ($page - 1) * $perPage; $data = array_slice($shuffled, $offset, $perPage); $lastPage = (int) ceil($total / $perPage); $hasMore = $page < $lastPage; $etag = sha1($baseHash.':'.$page.':'.$perPage.':'.$seedValue); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304) ->header('Cache-Control', 'public, max-age=120') ->header('Vary', 'Accept-Language, X-Locale') ->header('X-Content-Locale', $resolvedLocale) ->header('ETag', $etag); } $payload = [ 'data' => $data, 'meta' => [ 'total' => $total, 'per_page' => $perPage, 'current_page' => $page, 'last_page' => $lastPage, 'has_more' => $hasMore, 'seed' => $seedValue, ], ]; return response()->json($payload) ->header('Cache-Control', 'public, max-age=120') ->header('ETag', $etag) ->header('Vary', 'Accept-Language, X-Locale') ->header('X-Content-Locale', $resolvedLocale); } public function photos(Request $request, string $token) { $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $eventId = (int) $event->id; [$locale] = $this->resolveGuestLocale($request, $event); $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); $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', 'photos.created_at', 'photos.ingest_source', 'tasks.title as task_title', ]) ->where('photos.event_id', $eventId) ->where('photos.status', 'approved') ->orderByDesc('photos.created_at') ->limit(60); // MyPhotos filter if ($filter === 'photobooth') { $query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]); } elseif ($filter === 'myphotos' && $deviceId !== 'anon') { $query->where('guest_name', $deviceId); } if ($since) { $query->where('photos.created_at', '>', $since); } $rows = $query->get()->map(function ($r) use ($fallbacks, $token) { $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full') ?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? '')); $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail') ?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? '')); // Localize task title if present if ($r->task_title) { $r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe'); } $r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN; 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, $locale])); $reqEtag = $request->headers->get('If-None-Match'); if ($reqEtag && $reqEtag === $etag) { return response('', 304) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) ->header('Last-Modified', (string) $latestPhotoAt) ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') ->header('X-Content-Locale', $locale); } return response()->json($payload) ->header('Cache-Control', 'no-store') ->header('ETag', $etag) ->header('Last-Modified', (string) $latestPhotoAt) ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') ->header('X-Content-Locale', $locale); } public function photo(Request $request, int $id) { return ApiError::response( 'photo_not_found', 'Photo Not Found', 'Photo not found or event not public.', Response::HTTP_NOT_FOUND, ['photo_id' => $id] ); } 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; $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false); if ($demoReadOnly) { $this->recordTokenEvent( $joinToken, $request, 'demo_read_only', ['event_id' => $eventId], $token, Response::HTTP_FORBIDDEN ); return ApiError::response( 'demo_read_only', 'Demo mode', 'Uploads are disabled in demo mode.', Response::HTTP_FORBIDDEN, [ 'event_id' => $eventId, ] ); } $eventModel = Event::with([ 'tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget', ])->findOrFail($eventId); $policy = $this->guestPolicy(); $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility); $autoApproveUploads = $uploadVisibility === 'immediate'; $controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []); $controlRoom = is_array($controlRoom) ? $controlRoom : []; $autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', false); $trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []); $forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []); $autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads; $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) { if (($violation['code'] ?? null) === 'photo_limit_exceeded') { $this->notifyPhotoLimitReached($eventModel); } return ApiError::response( $violation['code'], $violation['title'], $violation['message'], $violation['status'], $violation['meta'] ); } $eventPackage = $this->packageLimitEvaluator ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); $deviceId = $this->resolveDeviceIdentifier($request); $deviceHasRule = static function (array $entries, string $deviceId): bool { foreach ($entries as $entry) { if (! is_array($entry)) { continue; } $candidate = $entry['device_id'] ?? null; if (is_string($candidate) && $candidate === $deviceId) { return true; } } return false; }; $deviceHasRules = $deviceId !== 'anonymous'; $isForceReviewUploader = $deviceHasRules && is_array($forceReviewUploaders) ? $deviceHasRule($forceReviewUploaders, $deviceId) : false; $isTrustedUploader = $deviceHasRules && is_array($trustedUploaders) ? $deviceHasRule($trustedUploaders, $deviceId) : false; if ($isForceReviewUploader) { $autoApproveUploads = false; } elseif ($isTrustedUploader) { $autoApproveUploads = true; } $autoAddApprovedToLive = $autoAddApprovedToLiveDefault && $autoApproveUploads; $deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50)); $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); if ($deviceLimit > 0 && $deviceCount >= $deviceLimit) { $this->notifyDeviceUploadLimit($eventModel, $deviceId, $token, $deviceLimit); $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' => $deviceLimit, 'used' => $deviceCount, ] ); } $validated = $request->validate([ 'photo' => ['required', 'image', 'mimes:jpeg,jpg,png,webp,heic,heif,gif', 'max:12288'], // 12 MB, block SVG/other image types 'emotion_id' => ['nullable', 'integer'], 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], 'guest_name' => ['nullable', 'string', 'max:255'], 'live_show_opt_in' => ['nullable', 'boolean'], ]); $file = $validated['photo']; $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel); $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file); $watermarkConfig = WatermarkConfigResolver::resolve($eventModel); // 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) : $this->resolveDiskUrl($disk, $path); // Create watermarked copies (non-destructive). $watermarkedPath = $path; $watermarkedThumb = $thumbPath ?: $path; if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { $watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path; if ($thumbPath) { $watermarkedThumb = ImageHelper::copyWithWatermark( $disk, $thumbPath, "events/{$eventId}/photos/watermarked/{$baseName}_thumb.jpg", $watermarkConfig ) ?? $thumbPath; } else { $watermarkedThumb = $watermarkedPath; } } $url = $this->resolveDiskUrl($disk, $watermarkedPath); $thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb); $liveShowSettings = Arr::get($eventModel->settings ?? [], 'live_show', []); $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : []; $liveModerationMode = $liveShowSettings['moderation_mode'] ?? 'manual'; $liveOptIn = $request->boolean('live_show_opt_in'); $liveSubmittedAt = null; $liveApprovedAt = null; $liveReviewedAt = null; $liveStatus = PhotoLiveStatus::NONE->value; $securityMeta = $isForceReviewUploader ? [ 'manual_review' => true, 'manual_review_reason' => 'force_review_device', ] : null; $securityMetaValue = $securityMeta ? json_encode($securityMeta) : null; if ($liveOptIn || $autoAddApprovedToLive) { $liveSubmittedAt = now(); if ($autoAddApprovedToLive) { $liveStatus = PhotoLiveStatus::APPROVED->value; $liveApprovedAt = $liveSubmittedAt; $liveReviewedAt = $liveSubmittedAt; } elseif ($liveModerationMode === 'off') { $liveStatus = PhotoLiveStatus::APPROVED->value; $liveApprovedAt = $liveSubmittedAt; $liveReviewedAt = $liveSubmittedAt; } else { $liveStatus = PhotoLiveStatus::PENDING->value; } } if ($isForceReviewUploader) { $liveStatus = PhotoLiveStatus::REJECTED->value; $liveSubmittedAt = null; $liveApprovedAt = null; $liveReviewedAt = now(); } $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, 'tenant_id' => $tenantModel->id, 'task_id' => $validated['task_id'] ?? null, 'guest_name' => $validated['guest_name'] ?? $deviceId, 'created_by_device_id' => $deviceId !== 'anonymous' ? $deviceId : null, 'file_path' => $url, 'thumbnail_path' => $thumbUrl, 'likes_count' => 0, 'ingest_source' => Photo::SOURCE_GUEST_PWA, 'status' => $autoApproveUploads ? 'approved' : 'pending', 'live_status' => $liveStatus, 'live_submitted_at' => $liveSubmittedAt, 'live_approved_at' => $liveApprovedAt, 'live_reviewed_at' => $liveReviewedAt, 'live_reviewed_by' => null, 'live_rejection_reason' => null, // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'is_featured' => 0, 'metadata' => null, 'security_meta' => $securityMetaValue, 'created_at' => now(), 'updated_at' => now(), ]); $storedPath = Storage::disk($disk)->path($path); $storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize(); $asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getClientMimeType(), 'size_bytes' => $storedSize, 'checksum' => file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()), 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, ]); $watermarkedAsset = null; if ($watermarkedPath !== $path) { $watermarkedAsset = $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPath, [ 'variant' => 'watermarked', 'mime_type' => $file->getClientMimeType(), 'size_bytes' => Storage::disk($disk)->exists($watermarkedPath) ? Storage::disk($disk)->size($watermarkedPath) : null, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } 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, ], ]); } if ($watermarkedThumb !== $thumbPath) { $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [ 'variant' => 'watermarked_thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, 'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb) ? Storage::disk($disk)->size($watermarkedThumb) : null, 'meta' => [ 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, ], ]); } DB::table('photos') ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); ProcessPhotoSecurityScan::dispatch($photoId); if ($eventPackage) { $previousUsed = (int) $eventPackage->used_photos; $eventPackage->increment('used_photos'); $eventPackage->refresh(); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1); } $message = $autoApproveUploads ? 'Photo uploaded and visible.' : 'Photo uploaded and pending review.'; $response = response()->json([ 'id' => $photoId, 'status' => $autoApproveUploads ? 'approved' : 'pending', 'message' => $message, ], 201); $this->recordTokenEvent( $joinToken, $request, 'upload_completed', [ 'event_id' => $eventId, 'photo_id' => $photoId, 'device_id' => $deviceId, ], $token, Response::HTTP_CREATED ); event(new GuestPhotoUploaded( $eventModel, $photoId, $deviceId, $validated['guest_name'] ?? null, )); 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', 'default_locale']); if ($result instanceof JsonResponse) { return $result; } [$event] = $result; $eventId = (int) $event->id; [$locale] = $this->resolveGuestLocale($request, $event); $fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null); $guestIdentifier = $this->determineGuestIdentifier($request); $cacheKey = sprintf( 'event:%d:achievements:%s:%s', $eventId, $locale, $guestIdentifier ? sha1($guestIdentifier) : 'public' ); $cached = Cache::remember($cacheKey, now()->addSeconds(60), function () use ($eventId, $identifier, $guestIdentifier, $fallbacks) { $payload = $this->buildAchievementsPayload($eventId, $identifier, $guestIdentifier, $fallbacks); return [ 'payload' => $payload, 'hash' => sha1(json_encode($payload)), ]; }); $payload = $cached['payload']; $etag = $cached['hash']; $ifNoneMatch = $request->headers->get('If-None-Match'); if ($ifNoneMatch && $ifNoneMatch === $etag) { return response('', 304) ->header('Cache-Control', 'public, max-age=60') ->header('ETag', $etag) ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') ->header('X-Content-Locale', $locale); } return response()->json($payload) ->header('Cache-Control', 'public, max-age=60') ->header('ETag', $etag) ->header('Vary', 'Accept-Language, X-Locale, X-Device-Id') ->header('X-Content-Locale', $locale); } 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; } } }