Files
fotospiel-app/app/Http/Controllers/Api/EventPublicController.php
2025-11-12 15:48:06 +01:00

2213 lines
76 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
use App\Services\Packages\PackageLimitEvaluator;
use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
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\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,
) {}
/**
* @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',
'Too Many Attempts',
'Too many invalid join token attempts. Try again later.',
Response::HTTP_TOO_MANY_REQUESTS,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey])
);
}
RateLimiter::hit($rateLimiterKey, $failureDecay * 60);
Log::notice('Join token access denied', array_merge([
'code' => $code,
'ip' => $request->ip(),
], $context));
$this->recordTokenEvent(
$joinToken,
$request,
$code,
array_merge($context, ['rate_limiter_key' => $rateLimiterKey]),
$rawToken,
$status
);
return ApiError::response(
$code,
$this->tokenErrorTitle($code),
$this->tokenErrorMessage($code),
$status,
$context
);
}
private function tokenErrorMessage(string $code): string
{
return match ($code) {
'invalid_token' => 'The provided join token is invalid.',
'token_expired' => 'The join token has expired.',
'token_revoked' => 'The join token has been revoked.',
default => 'Access denied.',
};
}
private function tokenErrorTitle(string $code): string
{
return match ($code) {
'invalid_token' => 'Invalid Join Token',
'token_expired' => 'Join Token Expired',
'token_revoked' => 'Join Token Revoked',
'token_rate_limited' => 'Join Token Rate Limited',
default => 'Access Denied',
};
}
private function recordTokenEvent(
?EventJoinToken $joinToken,
Request $request,
string $eventType,
array $context = [],
?string $rawToken = null,
?int $status = null
): void {
$this->analyticsRecorder->record($joinToken, $eventType, $request, $context, $rawToken, $status);
}
private function enforceAccessThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.access_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.access_decay_minutes', 1));
$key = sprintf('event:token:access:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'access_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return ApiError::response(
'access_rate_limited',
'Too Many Requests',
'Too many requests. Please slow down.',
Response::HTTP_TOO_MANY_REQUESTS,
[
'limit' => $limit,
'decay_minutes' => $decay,
]
);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function enforceDownloadThrottle(EventJoinToken $joinToken, Request $request, string $rawToken): ?JsonResponse
{
$limit = (int) config('join_tokens.download_limit', 0);
if ($limit <= 0) {
return null;
}
$decay = max(1, (int) config('join_tokens.download_decay_minutes', 1));
$key = sprintf('event:token:download:%s:%s', $joinToken->getKey(), $request->ip());
if (RateLimiter::tooManyAttempts($key, $limit)) {
$this->recordTokenEvent(
$joinToken,
$request,
'download_rate_limited',
[
'limit' => $limit,
'decay_minutes' => $decay,
],
$rawToken,
Response::HTTP_TOO_MANY_REQUESTS
);
return ApiError::response(
'download_rate_limited',
'Download Rate Limited',
'Download rate limit exceeded. Please wait a moment.',
Response::HTTP_TOO_MANY_REQUESTS,
[
'limit' => $limit,
'decay_minutes' => $decay,
]
);
}
RateLimiter::hit($key, $decay * 60);
return null;
}
private function getLocalized($value, $locale, $default = '')
{
if (is_string($value) && json_decode($value) !== null) {
$data = json_decode($value, true);
return $data[$locale] ?? $data['de'] ?? $default;
}
return $value ?: $default;
}
/**
* @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')
->join('task_collection_task', 'tasks.id', '=', 'task_collection_task.task_id')
->join('event_task_collection', 'task_collection_task.task_collection_id', '=', 'event_task_collection.task_collection_id')
->leftJoin('emotions', 'tasks.emotion_id', '=', 'emotions.id')
->where('event_task_collection.event_id', $eventId)
->select([
'tasks.id',
'tasks.title',
'tasks.description',
'tasks.example_text',
'tasks.emotion_id',
'tasks.sort_order',
'emotions.name as emotion_name',
'emotions.id as emotion_lookup_id',
])
->orderBy('event_task_collection.sort_order')
->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', '');
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120);
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId;
if ($candidate === '') {
return null;
}
return substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120);
}
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/')) {
return Storage::url($path);
}
// Absolute server paths pointing into storage/app/public (Linux/Windows)
$normalized = str_replace('\\', '/', $path);
$needle = '/storage/app/public/';
if (str_contains($normalized, $needle)) {
$rel = substr($normalized, strpos($normalized, $needle) + strlen($needle));
return '/storage/'.ltrim($rel, '/');
}
return $path; // fallback as-is
}
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
{
if (is_array($value)) {
$primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null;
return $primary ?? (reset($value) ?: $fallback);
}
if (is_string($value) && $value !== '') {
return $value;
}
return $fallback;
}
private function buildGalleryBranding(Event $event): array
{
$defaultPrimary = '#f43f5e';
$defaultSecondary = '#fb7185';
$defaultBackground = '#ffffff';
$eventBranding = Arr::get($event->settings, 'branding', []);
$tenantBranding = Arr::get($event->tenant?->settings, 'branding', []);
return [
'primary_color' => Arr::get($eventBranding, 'primary_color')
?? Arr::get($tenantBranding, 'primary_color')
?? $defaultPrimary,
'secondary_color' => Arr::get($eventBranding, 'secondary_color')
?? Arr::get($tenantBranding, 'secondary_color')
?? $defaultSecondary,
'background_color' => Arr::get($eventBranding, 'background_color')
?? Arr::get($tenantBranding, 'background_color')
?? $defaultBackground,
];
}
private function encodeGalleryCursor(Photo $photo): string
{
return base64_encode(json_encode([
'id' => $photo->id,
'created_at' => $photo->created_at?->toIso8601String(),
]));
}
private function decodeGalleryCursor(?string $cursor): ?array
{
if (! $cursor) {
return null;
}
$decoded = json_decode(base64_decode($cursor, true) ?: '', true);
if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) {
return null;
}
try {
return [
'id' => (int) $decoded['id'],
'created_at' => Carbon::parse($decoded['created_at']),
];
} catch (\Throwable $e) {
return null;
}
}
private function makeGalleryPhotoResource(Photo $photo, string $token): array
{
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
return [
'id' => $photo->id,
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
'full_url' => $fullUrl,
'download_url' => $downloadUrl,
'likes_count' => $photo->likes_count,
'guest_name' => $photo->guest_name,
'created_at' => $photo->created_at?->toIso8601String(),
];
}
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
{
if (! in_array($variant, ['thumbnail', 'full'], true)) {
return null;
}
return URL::temporarySignedRoute(
'api.v1.gallery.photos.asset',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
'variant' => $variant,
]
);
}
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
{
return URL::temporarySignedRoute(
'api.v1.gallery.photos.download',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'token' => $token,
'photo' => $photo->id,
]
);
}
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,
]
);
}
public function gallery(Request $request, string $token)
{
$locale = $request->query('locale', app()->getLocale());
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$branding = $this->buildGalleryBranding($event);
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
return response()->json([
'event' => [
'id' => $event->id,
'name' => $this->translateLocalized($event->name, $locale, 'Fotospiel Event'),
'slug' => $event->slug,
'description' => $this->translateLocalized($event->description, $locale, ''),
'gallery_expires_at' => $expiresAt?->toIso8601String(),
],
'branding' => $branding,
]);
}
public function galleryPhotos(Request $request, string $token)
{
$resolved = $this->resolveGalleryEvent($request, $token);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: Event, 1: EventJoinToken} $resolved */
[$event, $joinToken] = $resolved;
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
return $downloadResponse;
}
$limit = (int) $request->query('limit', 30);
$limit = max(1, min($limit, 60));
$cursor = $this->decodeGalleryCursor($request->query('cursor'));
$query = Photo::query()
->where('event_id', $event->id)
->where('status', 'approved')
->orderByDesc('created_at')
->orderByDesc('id');
if ($cursor) {
/** @var Carbon $cursorTime */
$cursorTime = $cursor['created_at'];
$cursorId = $cursor['id'];
$query->where(function ($inner) use ($cursorTime, $cursorId) {
$inner->where('created_at', '<', $cursorTime)
->orWhere(function ($nested) use ($cursorTime, $cursorId) {
$nested->where('created_at', '=', $cursorTime)
->where('id', '<', $cursorId);
});
});
}
$photos = $query->limit($limit + 1)->get();
$hasMore = $photos->count() > $limit;
$items = $photos->take($limit);
$nextCursor = null;
if ($hasMore) {
$cursorPhoto = $photos->slice($limit, 1)->first();
if ($cursorPhoto instanceof Photo) {
$nextCursor = $this->encodeGalleryCursor($cursorPhoto);
}
}
return response()->json([
'data' => $items->map(fn (Photo $photo) => $this->makeGalleryPhotoResource($photo, $token))->all(),
'next_cursor' => $nextCursor,
]);
}
public function 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 || $photo->status !== 'approved') {
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;
}
}
$photoResource = [
'id' => $photo->id,
'title' => $taskTitle,
'emotion' => $photo->emotion ? [
'name' => $photo->emotion->name,
'emoji' => $photo->emotion->emoji,
] : null,
'likes_count' => $photo->likes()->count(),
'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 || $photo->status !== 'approved') {
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'];
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);
$fontFamily = Arr::get($event->settings, 'branding.font_family')
?? Arr::get($event->tenant?->settings, 'branding.font_family');
$logoUrl = Arr::get($event->settings, 'branding.logo_url')
?? Arr::get($event->tenant?->settings, 'branding.logo_url');
if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken);
}
return response()->json([
'id' => $event->id,
'slug' => $event->slug,
'name' => $localizedName,
'default_locale' => $event->default_locale,
'created_at' => $event->created_at,
'updated_at' => $event->updated_at,
'type' => $eventTypeData,
'join_token' => $joinToken?->token,
'branding' => [
'primary_color' => $branding['primary_color'],
'secondary_color' => $branding['secondary_color'],
'background_color' => $branding['background_color'],
'font_family' => $fontFamily,
'logo_url' => $this->toPublicUrl($logoUrl),
],
])->header('Cache-Control', 'no-store');
}
public function package(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$eventRecord, $joinToken] = $result;
$event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])
->findOrFail($eventRecord->id);
if (! $event->tenant) {
return ApiError::response(
'event_not_found',
'Event not accessible',
'The selected event is no longer available.',
Response::HTTP_NOT_FOUND,
['scope' => 'photos', 'event_id' => $event->id]
);
}
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$event->tenant,
$event->id,
$event
);
if (! $eventPackage || ! $eventPackage->package) {
return response()->json([
'id' => null,
'event_id' => $event->id,
'package_id' => null,
'package' => null,
'used_photos' => (int) ($eventPackage?->used_photos ?? 0),
'used_guests' => (int) ($eventPackage?->used_guests ?? 0),
'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(),
'limits' => null,
])->header('Cache-Control', 'no-store');
}
$package = $eventPackage->package;
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
return response()->json([
'id' => $eventPackage->id,
'event_id' => $event->id,
'package_id' => $eventPackage->package_id,
'package' => [
'id' => $eventPackage->package_id,
'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name,
'max_photos' => $package?->max_photos,
'max_guests' => $package?->max_guests,
'gallery_days' => $package?->gallery_days,
],
'used_photos' => (int) $eventPackage->used_photos,
'used_guests' => (int) $eventPackage->used_guests,
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
'limits' => $summary,
])->header('Cache-Control', 'no-store');
}
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
{
foreach ($variantPreference as $variant) {
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
if (! $path) {
continue;
}
$diskName = $this->resolveStorageDisk($disk);
try {
$storage = Storage::disk($diskName);
} catch (\Throwable $e) {
Log::warning('Gallery asset disk unavailable', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'error' => $e->getMessage(),
]);
continue;
}
foreach ($this->storagePathCandidates($path) as $candidate) {
try {
if (! $storage->exists($candidate)) {
continue;
}
$stream = $storage->readStream($candidate);
if (! $stream) {
continue;
}
$size = null;
try {
$size = $storage->size($candidate);
} catch (\Throwable $e) {
$size = null;
}
if (! $mime) {
try {
$mime = $storage->mimeType($candidate);
} catch (\Throwable $e) {
$mime = null;
}
}
$extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
$suffix = $variant === 'thumbnail' ? '-thumb' : '';
$filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');
$headers = [
'Content-Type' => $mime ?? 'image/jpeg',
'Cache-Control' => $disposition === 'attachment'
? 'no-store, max-age=0'
: 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
];
if ($size) {
$headers['Content-Length'] = $size;
}
return response()->stream(function () use ($stream) {
fpassthru($stream);
fclose($stream);
}, 200, $headers);
} catch (\Throwable $e) {
Log::warning('Gallery asset stream error', [
'event_id' => $event->id,
'photo_id' => $record->id,
'disk' => $diskName,
'path' => $candidate,
'variant' => $variant,
'error' => $e->getMessage(),
]);
}
}
}
$fallbackUrl = $this->toPublicUrl($record->file_path ?? null);
if ($fallbackUrl) {
return redirect()->away($fallbackUrl);
}
return ApiError::response(
'photo_unavailable',
'Photo Unavailable',
'The requested photo could not be loaded.',
Response::HTTP_NOT_FOUND,
[
'photo_id' => $record->id,
'event_id' => $event->id,
]
);
}
private function resolvePhotoVariant(Photo $record, string $variant): array
{
if ($variant === 'thumbnail') {
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $asset?->mime_type ?? 'image/jpeg';
} else {
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->file_path ?? null);
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
}
return [
$disk ?: config('filesystems.default', 'public'),
$path,
$mime,
];
}
private function resolveStorageDisk(?string $disk): string
{
$disk = $disk ?: config('filesystems.default', 'public');
if (! config("filesystems.disks.{$disk}")) {
return config('filesystems.default', 'public');
}
return $disk;
}
private function storagePathCandidates(string $path): array
{
$normalized = str_replace('\\', '/', $path);
$candidates = [$normalized];
$trimmed = ltrim($normalized, '/');
if ($trimmed !== $normalized) {
$candidates[] = $trimmed;
}
if (str_starts_with($trimmed, 'storage/')) {
$candidates[] = substr($trimmed, strlen('storage/'));
}
if (str_starts_with($trimmed, 'public/')) {
$candidates[] = substr($trimmed, strlen('public/'));
}
$needle = '/storage/app/public/';
if (str_contains($normalized, $needle)) {
$candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
}
return array_values(array_unique(array_filter($candidates)));
}
public function stats(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event, $joinToken] = $result;
$eventId = $event->id;
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
// Approximate online guests as distinct recent uploaders in last 10 minutes.
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
$onlineGuests = DB::table('photos')
->where('event_id', $eventId)
->where('created_at', '>=', $tenMinutesAgo)
->distinct('guest_name')
->count('guest_name');
// Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
$payload = [
'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved,
'latest_photo_at' => $latestPhotoAt,
];
$etag = sha1(json_encode($payload));
$reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) {
return response('', 304);
}
return response()->json($payload)
->header('Cache-Control', 'no-store')
->header('ETag', $etag);
}
public function emotions(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id', '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) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
$eventPackage = $this->packageLimitEvaluator
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64) ?: 'anon';
// Per-device cap per event (MVP: 50)
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
if ($deviceCount >= 50) {
$this->recordTokenEvent(
$joinToken,
$request,
'upload_device_limit',
[
'event_id' => $eventId,
'device_id' => $deviceId,
'device_count' => $deviceCount,
],
$token,
Response::HTTP_TOO_MANY_REQUESTS
);
return ApiError::response(
'upload_device_limit',
'Device upload limit reached',
'This device cannot upload more photos for this event.',
Response::HTTP_TOO_MANY_REQUESTS,
[
'scope' => 'photos',
'event_id' => $eventId,
'device_id' => $deviceId,
'limit' => 50,
'used' => $deviceCount,
]
);
}
$validated = $request->validate([
'photo' => ['required', 'image', 'max:6144'], // 6 MB
'emotion_id' => ['nullable', 'integer'],
'emotion_slug' => ['nullable', 'string'],
'task_id' => ['nullable', 'integer'],
'guest_name' => ['nullable', 'string', 'max:255'],
]);
$file = $validated['photo'];
$disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
$path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
$url = $this->resolveDiskUrl($disk, $path);
// Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME);
$thumbRel = "events/{$eventId}/photos/thumbs/{$baseName}_thumb.jpg";
$thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath
? $this->resolveDiskUrl($disk, $thumbPath)
: $url;
$photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId,
'task_id' => $validated['task_id'] ?? null,
'guest_name' => $validated['guest_name'] ?? $deviceId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0,
'metadata' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [
'variant' => 'original',
'mime_type' => $file->getClientMimeType(),
'size_bytes' => $file->getSize(),
'checksum' => hash_file('sha256', $file->getRealPath()),
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
]);
if ($thumbPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [
'variant' => 'thumbnail',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
'size_bytes' => Storage::disk($disk)->exists($thumbPath)
? Storage::disk($disk)->size($thumbPath)
: null,
'meta' => [
'source_variant_id' => $asset->id,
],
]);
}
DB::table('photos')
->where('id', $photoId)
->update(['media_asset_id' => $asset->id]);
if ($eventPackage) {
$previousUsed = (int) $eventPackage->used_photos;
$eventPackage->increment('used_photos');
$eventPackage->refresh();
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
}
$response = response()->json([
'id' => $photoId,
'file_path' => $url,
'thumbnail_path' => $thumbUrl,
], 201);
$this->recordTokenEvent(
$joinToken,
$request,
'upload_completed',
[
'event_id' => $eventId,
'photo_id' => $photoId,
'device_id' => $deviceId,
],
$token,
Response::HTTP_CREATED
);
return $response;
}
/**
* Resolve emotion_id from validation data, supporting both direct ID and slug lookup
*/
private function resolveEmotionId(array $validated, int $eventId): int
{
// 1. Use explicit emotion_id if provided
if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) {
return (int) $validated['emotion_id'];
}
// 2. Resolve from emotion_slug if provided (format: 'emotion-{id}')
if (isset($validated['emotion_slug']) && $validated['emotion_slug']) {
$slug = $validated['emotion_slug'];
if (str_starts_with($slug, 'emotion-')) {
$id = (int) substr($slug, 8); // Remove 'emotion-' prefix
if ($id > 0) {
// Verify emotion exists and is available for this event
$exists = DB::table('emotions')
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
->where('emotions.id', $id)
->where('events.id', $eventId)
->exists();
if ($exists) {
return $id;
}
}
}
}
// 3. Fallback: Get first available emotion for this event type
$defaultEmotion = DB::table('emotions')
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
->where('events.id', $eventId)
->orderBy('emotions.sort_order')
->value('emotions.id');
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
}
public function achievements(Request $request, string $identifier)
{
$result = $this->resolvePublishedEvent($request, $identifier, ['id', '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;
}
}
}