Files
fotospiel-app/app/Http/Controllers/Api/EventPublicController.php

2881 lines
103 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers\Api;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationDeliveryStatus;
use App\Enums\GuestNotificationState;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
use App\Models\GuestNotification;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
use App\Services\GuestNotificationService;
use App\Services\Packages\PackageLimitEvaluator;
use App\Services\Packages\PackageUsageTracker;
use App\Services\PushSubscriptionService;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\WatermarkConfigResolver;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EventPublicController extends BaseController
{
private const SIGNED_URL_TTL_SECONDS = 1800;
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly EventStorageManager $eventStorageManager,
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
private readonly PackageLimitEvaluator $packageLimitEvaluator,
private readonly PackageUsageTracker $packageUsageTracker,
private readonly EventTasksCacheService $eventTasksCache,
private readonly GuestNotificationService $guestNotificationService,
private readonly PushSubscriptionService $pushSubscriptions,
) {}
/**
* @return JsonResponse|array{0: object, 1: EventJoinToken}
*/
private function resolvePublishedEvent(Request $request, string $token, array $columns = ['id']): JsonResponse|array
{
$rateLimiterKey = sprintf('event:token:%s:%s', $token, $request->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',
$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
{
$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;
}
/**
* @param array{tasks: array<int, array<string, mixed>>, 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')
->limit(20)
->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;
}
private function buildAchievementsPayload(int $eventId, ?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)
->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->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->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)
->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->toPublicUrl($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 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/') || str_starts_with($path, 'branding/')) {
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 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<int, array|mixed> $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<int, array|mixed> $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' => '#f43f5e',
'secondary' => '#fb7185',
'background' => '#ffffff',
'surface' => '#ffffff',
'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->toPublicUrl($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 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,
]
);
}
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 : [];
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'] ?? true),
'guest_sharing_enabled' => (bool) ($settings['guest_sharing_enabled'] ?? true),
],
'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 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', '')));
$ttlHours = max(1, (int) 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 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);
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,
'photobooth_enabled' => (bool) $event->photobooth_enabled,
'branding' => $branding,
])->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(),
]);
}
}
}
$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, 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)));
if (! Schema::hasTable('guest_notifications')) {
return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
}
$baseQuery = GuestNotification::query()
->where('event_id', $event->id)
->active()
->notExpired()
->visibleToGuest($guestIdentifier);
$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): void
{
$title = 'Upload-Limit erreicht';
$body = 'Du hast bereits 50 Fotos hochgeladen. Wir speichern deine Warteschlange bitte lösche zuerst alte Uploads.';
$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 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']);
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', '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']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event, $joinToken] = $result;
[$resolvedLocale] = $this->resolveGuestLocale($request, $event);
$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'];
$etag = $cached['hash'] ?? sha1(json_encode($tasks));
$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);
}
return response()->json($tasks)
->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)
->orderByDesc('photos.created_at')
->limit(60);
// MyPhotos filter
if ($filter === 'photobooth') {
$query->where('photos.ingest_source', Photo::SOURCE_PHOTOBOOTH);
} 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) {
$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->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)
{
$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',
'events.default_locale',
])
->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 ?? ''));
$event = (object) [
'id' => $row->event_id,
'default_locale' => $row->default_locale ?? null,
];
[$locale] = $this->resolveGuestLocale($request, $event);
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
if ($row->task_title) {
$row->task_title = $this->firstLocalizedValue($row->task_title, $fallbacks, 'Unbenannte Aufgabe');
}
unset($row->default_locale);
return response()->json($row)
->header('Cache-Control', 'no-store')
->header('X-Content-Locale', $locale)
->header('Vary', 'Accept-Language, X-Locale');
}
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) {
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);
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
$this->notifyDeviceUploadLimit($eventModel, $deviceId, $token);
$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:12288'], // 12 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);
$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);
$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,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
'status' => 'approved',
// 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(),
]);
$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]);
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
);
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, $guestIdentifier, $fallbacks) {
$payload = $this->buildAchievementsPayload($eventId, $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;
}
}
}