3103 lines
111 KiB
PHP
3103 lines
111 KiB
PHP
<?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\Jobs\ProcessPhotoSecurityScan;
|
||
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;
|
||
|
||
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||
|
||
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')
|
||
->distinct('tasks.id')
|
||
->get();
|
||
|
||
$tasks = $rows->map(function ($row) use ($fallbacks) {
|
||
$emotion = null;
|
||
|
||
if ($row->emotion_id) {
|
||
$emotionName = $this->firstLocalizedValue($row->emotion_name, $fallbacks, 'Unbekannte Emotion');
|
||
|
||
if ($emotionName !== '') {
|
||
$emotionId = (int) ($row->emotion_lookup_id ?? $row->emotion_id);
|
||
$emotion = [
|
||
'slug' => 'emotion-'.$emotionId,
|
||
'name' => $emotionName,
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'id' => (int) $row->id,
|
||
'title' => $this->firstLocalizedValue($row->title, $fallbacks, 'Unbenannte Aufgabe'),
|
||
'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
|
||
'instructions' => $this->firstLocalizedValue($row->example_text, $fallbacks, ''),
|
||
'duration' => 3,
|
||
'is_completed' => false,
|
||
'emotion' => $emotion,
|
||
];
|
||
})->values()->all();
|
||
|
||
return [
|
||
'tasks' => $tasks,
|
||
'hash' => sha1(json_encode($tasks)),
|
||
];
|
||
}
|
||
|
||
private function resolveGuestLocale(Request $request, object $event): array
|
||
{
|
||
$supported = $this->eventTasksCache->supportedLocales();
|
||
|
||
$queryLocale = $this->normalizeLocale($request->query('locale', $request->query('lang')), $supported);
|
||
if ($queryLocale) {
|
||
return [$queryLocale, 'query'];
|
||
}
|
||
|
||
$headerLocale = $this->normalizeLocale($request->header('X-Locale'), $supported);
|
||
if ($headerLocale) {
|
||
return [$headerLocale, 'header'];
|
||
}
|
||
|
||
$acceptedLocale = $this->normalizeLocale($request->getPreferredLanguage($supported), $supported);
|
||
if ($acceptedLocale) {
|
||
return [$acceptedLocale, 'accept-language'];
|
||
}
|
||
|
||
$eventLocale = $this->normalizeLocale($event->default_locale ?? null, $supported);
|
||
if ($eventLocale) {
|
||
return [$eventLocale, 'event'];
|
||
}
|
||
|
||
$fallbackLocale = $this->normalizeLocale(config('app.fallback_locale'), $supported);
|
||
if ($fallbackLocale) {
|
||
return [$fallbackLocale, 'fallback'];
|
||
}
|
||
|
||
return [$supported[0] ?? 'de', 'fallback'];
|
||
}
|
||
|
||
private function localeFallbackChain(string $preferred, ?string $eventDefault): array
|
||
{
|
||
$chain = array_filter([
|
||
$preferred,
|
||
$this->normalizeLocale($eventDefault),
|
||
$this->normalizeLocale(config('app.fallback_locale')),
|
||
'de',
|
||
'en',
|
||
]);
|
||
|
||
return array_values(array_unique($chain));
|
||
}
|
||
|
||
private function normalizeLocale(?string $value, ?array $allowed = null): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
|
||
$normalized = Str::of($value)
|
||
->lower()
|
||
->replace('_', '-')
|
||
->before('-')
|
||
->trim()
|
||
->value();
|
||
|
||
if ($normalized === '') {
|
||
return null;
|
||
}
|
||
|
||
if ($allowed !== null && ! in_array($normalized, $allowed, true)) {
|
||
return null;
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
|
||
private function decodeTranslations(mixed $value): array
|
||
{
|
||
if (is_array($value)) {
|
||
return $value;
|
||
}
|
||
|
||
if (is_string($value) && $value !== '') {
|
||
$decoded = json_decode($value, true);
|
||
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||
return $decoded;
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
private function firstLocalizedValue(mixed $value, array $locales, string $default = ''): string
|
||
{
|
||
$translations = $this->decodeTranslations($value);
|
||
|
||
foreach ($locales as $locale) {
|
||
$candidate = $translations[$locale] ?? null;
|
||
|
||
if (is_string($candidate) && trim($candidate) !== '') {
|
||
return $candidate;
|
||
}
|
||
}
|
||
|
||
if (is_string($value) && trim($value) !== '') {
|
||
return $value;
|
||
}
|
||
|
||
if (! empty($translations)) {
|
||
$first = reset($translations);
|
||
if (is_string($first) && trim($first) !== '') {
|
||
return $first;
|
||
}
|
||
}
|
||
|
||
return $default;
|
||
}
|
||
|
||
private function determineGuestIdentifier(Request $request): ?string
|
||
{
|
||
$guestNameParam = trim((string) $request->query('guest_name', ''));
|
||
$deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
|
||
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceIdHeader;
|
||
$normalized = $this->normalizeGuestIdentifier($candidate);
|
||
|
||
if ($normalized === '') {
|
||
return null;
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
|
||
private function normalizeGuestIdentifier(string $value): string
|
||
{
|
||
$cleaned = preg_replace('/[^\p{L}\p{N}\s_\-]/u', '', $value) ?? '';
|
||
$trimmed = trim(mb_substr($cleaned, 0, 120));
|
||
|
||
return $trimmed;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function normalizeSettings(array|string|null $settings): array
|
||
{
|
||
if (is_array($settings)) {
|
||
return $settings;
|
||
}
|
||
|
||
if (is_string($settings)) {
|
||
$decoded = json_decode($settings, true);
|
||
if (is_array($decoded)) {
|
||
return $decoded;
|
||
}
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
private function buildDeterministicSeed(?string $identifier): int
|
||
{
|
||
if ($identifier === null || trim($identifier) === '') {
|
||
return random_int(1, PHP_INT_MAX);
|
||
}
|
||
|
||
$hash = substr(sha1($identifier), 0, 8);
|
||
$seed = hexdec($hash);
|
||
|
||
if (! is_numeric($seed) || $seed <= 0) {
|
||
$seed = abs(crc32($identifier));
|
||
}
|
||
|
||
return max(1, (int) $seed);
|
||
}
|
||
|
||
private function buildAchievementsPayload(int $eventId, string $token, ?string $guestIdentifier, array $fallbacks): array
|
||
{
|
||
$totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
|
||
$uniqueGuests = (int) DB::table('photos')->where('event_id', $eventId)->distinct('guest_name')->count('guest_name');
|
||
$tasksSolved = (int) DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
|
||
$likesTotal = (int) DB::table('photos')->where('event_id', $eventId)->sum('likes_count');
|
||
|
||
$summary = [
|
||
'total_photos' => $totalPhotos,
|
||
'unique_guests' => $uniqueGuests,
|
||
'tasks_solved' => $tasksSolved,
|
||
'likes_total' => $likesTotal,
|
||
];
|
||
|
||
$personal = null;
|
||
if ($guestIdentifier) {
|
||
$personalPhotos = (int) DB::table('photos')
|
||
->where('event_id', $eventId)
|
||
->where('guest_name', $guestIdentifier)
|
||
->count();
|
||
|
||
$personalTasks = (int) DB::table('photos')
|
||
->where('event_id', $eventId)
|
||
->where('guest_name', $guestIdentifier)
|
||
->whereNotNull('task_id')
|
||
->distinct('task_id')
|
||
->count('task_id');
|
||
|
||
$personalLikes = (int) DB::table('photos')
|
||
->where('event_id', $eventId)
|
||
->where('guest_name', $guestIdentifier)
|
||
->sum('likes_count');
|
||
|
||
$badgeBlueprints = [
|
||
['id' => 'first_photo', 'title' => 'Erstes Foto', 'description' => 'Lade dein erstes Foto hoch.', 'target' => 1, 'metric' => 'photos'],
|
||
['id' => 'five_photos', 'title' => 'Fotoflair', 'description' => 'Fuenf Fotos hochgeladen.', 'target' => 5, 'metric' => 'photos'],
|
||
['id' => 'first_task', 'title' => 'Aufgabenstarter', 'description' => 'Erste Aufgabe erfuellt.', 'target' => 1, 'metric' => 'tasks'],
|
||
['id' => 'task_master', 'title' => 'Aufgabenmeister', 'description' => 'Fuenf Aufgaben erfuellt.', 'target' => 5, 'metric' => 'tasks'],
|
||
['id' => 'crowd_pleaser', 'title' => 'Publikumsliebling', 'description' => 'Sammle zwanzig Likes.', 'target' => 20, 'metric' => 'likes'],
|
||
];
|
||
|
||
$personalBadges = [];
|
||
foreach ($badgeBlueprints as $badge) {
|
||
$value = match ($badge['metric']) {
|
||
'photos' => $personalPhotos,
|
||
'tasks' => $personalTasks,
|
||
default => $personalLikes,
|
||
};
|
||
|
||
$personalBadges[] = [
|
||
'id' => $badge['id'],
|
||
'title' => $badge['title'],
|
||
'description' => $badge['description'],
|
||
'earned' => $value >= $badge['target'],
|
||
'progress' => $value,
|
||
'target' => $badge['target'],
|
||
];
|
||
}
|
||
|
||
$personal = [
|
||
'guest_name' => $guestIdentifier,
|
||
'photos' => $personalPhotos,
|
||
'tasks' => $personalTasks,
|
||
'likes' => $personalLikes,
|
||
'badges' => $personalBadges,
|
||
];
|
||
}
|
||
|
||
$topUploads = DB::table('photos')
|
||
->select('guest_name', DB::raw('COUNT(*) as total'), DB::raw('SUM(likes_count) as likes'))
|
||
->where('event_id', $eventId)
|
||
->groupBy('guest_name')
|
||
->orderByDesc('total')
|
||
->limit(5)
|
||
->get()
|
||
->map(fn ($row) => [
|
||
'guest' => $row->guest_name,
|
||
'photos' => (int) $row->total,
|
||
'likes' => (int) $row->likes,
|
||
])
|
||
->values();
|
||
|
||
$topLikes = DB::table('photos')
|
||
->select('guest_name', DB::raw('SUM(likes_count) as likes'), DB::raw('COUNT(*) as total'))
|
||
->where('event_id', $eventId)
|
||
->groupBy('guest_name')
|
||
->orderByDesc('likes')
|
||
->limit(5)
|
||
->get()
|
||
->map(fn ($row) => [
|
||
'guest' => $row->guest_name,
|
||
'likes' => (int) $row->likes,
|
||
'photos' => (int) $row->total,
|
||
])
|
||
->values();
|
||
|
||
$topPhotoRow = DB::table('photos')
|
||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||
->where('photos.event_id', $eventId)
|
||
->where('photos.status', 'approved')
|
||
->orderByDesc('photos.likes_count')
|
||
->orderByDesc('photos.created_at')
|
||
->select([
|
||
'photos.id',
|
||
'photos.guest_name',
|
||
'photos.likes_count',
|
||
'photos.file_path',
|
||
'photos.thumbnail_path',
|
||
'photos.created_at',
|
||
'tasks.title as task_title',
|
||
])
|
||
->first();
|
||
|
||
$topPhoto = $topPhotoRow ? [
|
||
'photo_id' => (int) $topPhotoRow->id,
|
||
'guest' => $topPhotoRow->guest_name,
|
||
'likes' => (int) $topPhotoRow->likes_count,
|
||
'task' => $this->firstLocalizedValue($topPhotoRow->task_title, $fallbacks, '') ?: null,
|
||
'created_at' => $topPhotoRow->created_at,
|
||
'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $topPhotoRow->id, 'thumbnail')
|
||
?? $this->resolveSignedFallbackUrl($topPhotoRow->thumbnail_path ?: $topPhotoRow->file_path),
|
||
] : null;
|
||
|
||
$trendingEmotionRow = DB::table('photos')
|
||
->join('emotions', 'photos.emotion_id', '=', 'emotions.id')
|
||
->where('photos.event_id', $eventId)
|
||
->select('emotions.id', 'emotions.name', DB::raw('COUNT(*) as total'))
|
||
->groupBy('emotions.id', 'emotions.name')
|
||
->orderByDesc('total')
|
||
->first();
|
||
|
||
$trendingEmotion = $trendingEmotionRow ? [
|
||
'emotion_id' => (int) $trendingEmotionRow->id,
|
||
'name' => $this->firstLocalizedValue($trendingEmotionRow->name, $fallbacks, 'Emotion'),
|
||
'count' => (int) $trendingEmotionRow->total,
|
||
] : null;
|
||
|
||
$timeline = DB::table('photos')
|
||
->where('event_id', $eventId)
|
||
->selectRaw('DATE(created_at) as day, COUNT(*) as photos, COUNT(DISTINCT guest_name) as guests')
|
||
->groupBy('day')
|
||
->orderBy('day')
|
||
->limit(14)
|
||
->get()
|
||
->map(fn ($row) => [
|
||
'date' => $row->day,
|
||
'photos' => (int) $row->photos,
|
||
'guests' => (int) $row->guests,
|
||
])
|
||
->values();
|
||
|
||
$feed = DB::table('photos')
|
||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||
->where('photos.event_id', $eventId)
|
||
->where('photos.status', 'approved')
|
||
->orderByDesc('photos.created_at')
|
||
->limit(12)
|
||
->get([
|
||
'photos.id',
|
||
'photos.guest_name',
|
||
'photos.thumbnail_path',
|
||
'photos.file_path',
|
||
'photos.likes_count',
|
||
'photos.created_at',
|
||
'tasks.title as task_title',
|
||
])
|
||
->map(fn ($row) => [
|
||
'photo_id' => (int) $row->id,
|
||
'guest' => $row->guest_name,
|
||
'task' => $this->firstLocalizedValue($row->task_title, $fallbacks, '') ?: null,
|
||
'likes' => (int) $row->likes_count,
|
||
'created_at' => $row->created_at,
|
||
'thumbnail' => $this->makeSignedGalleryAssetUrlForId($token, (int) $row->id, 'thumbnail')
|
||
?? $this->resolveSignedFallbackUrl($row->thumbnail_path ?: $row->file_path),
|
||
])
|
||
->values();
|
||
|
||
return [
|
||
'summary' => $summary,
|
||
'personal' => $personal,
|
||
'leaderboards' => [
|
||
'uploads' => $topUploads,
|
||
'likes' => $topLikes,
|
||
],
|
||
'highlights' => [
|
||
'top_photo' => $topPhoto,
|
||
'trending_emotion' => $trendingEmotion,
|
||
'timeline' => $timeline,
|
||
],
|
||
'feed' => $feed,
|
||
];
|
||
}
|
||
|
||
private function resolveSignedFallbackUrl(?string $path): ?string
|
||
{
|
||
if (! $path) {
|
||
return null;
|
||
}
|
||
|
||
// If already signed or absolute, return as-is
|
||
if (str_contains($path, 'signature=')
|
||
|| str_starts_with($path, 'http://')
|
||
|| str_starts_with($path, 'https://')) {
|
||
return $path;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function translateLocalized(string|array|null $value, string $locale, string $fallback = ''): string
|
||
{
|
||
if (is_array($value)) {
|
||
$primary = $value[$locale] ?? $value['de'] ?? $value['en'] ?? null;
|
||
|
||
return $primary ?? (reset($value) ?: $fallback);
|
||
}
|
||
|
||
if (is_string($value) && $value !== '') {
|
||
return $value;
|
||
}
|
||
|
||
return $fallback;
|
||
}
|
||
|
||
private function determineBrandingAllowed(Event $event): bool
|
||
{
|
||
return WatermarkConfigResolver::determineBrandingAllowed($event);
|
||
}
|
||
|
||
private function determineWatermarkPolicy(Event $event): string
|
||
{
|
||
return WatermarkConfigResolver::determinePolicy($event);
|
||
}
|
||
|
||
private function resolveWatermarkConfig(Event $event): array
|
||
{
|
||
return WatermarkConfigResolver::resolve($event);
|
||
}
|
||
|
||
private function buildGalleryBranding(Event $event): array
|
||
{
|
||
return $this->resolveBrandingPayload($event);
|
||
}
|
||
|
||
private function normalizeHexColor(?string $value, string $fallback): string
|
||
{
|
||
if (is_string($value)) {
|
||
$trimmed = trim($value);
|
||
|
||
if (preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $trimmed) === 1) {
|
||
return $trimmed;
|
||
}
|
||
}
|
||
|
||
return $fallback;
|
||
}
|
||
|
||
/**
|
||
* @param array<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->makeSignedBrandingUrl($logoRawValue) : $logoRawValue;
|
||
|
||
$buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style'];
|
||
if (! in_array($buttonStyle, ['filled', 'outline'], true)) {
|
||
$buttonStyle = $defaults['button_style'];
|
||
}
|
||
|
||
$buttonRadius = (int) ($this->firstNumberFromSources($sources, ['buttons.radius', 'button_radius']) ?? $defaults['button_radius']);
|
||
$buttonRadius = $buttonRadius > 0 ? $buttonRadius : $defaults['button_radius'];
|
||
|
||
$buttonPrimary = $this->normalizeHexColor(
|
||
$this->firstStringFromSources($sources, ['buttons.primary', 'button_primary_color']),
|
||
$primary
|
||
);
|
||
$buttonSecondary = $this->normalizeHexColor(
|
||
$this->firstStringFromSources($sources, ['buttons.secondary', 'button_secondary_color']),
|
||
$secondary
|
||
);
|
||
$linkColor = $this->normalizeHexColor(
|
||
$this->firstStringFromSources($sources, ['buttons.link_color', 'link_color']),
|
||
$secondary
|
||
);
|
||
|
||
$mode = $this->firstStringFromSources($sources, ['mode']) ?? $defaults['mode'];
|
||
if (! in_array($mode, ['light', 'dark', 'auto'], true)) {
|
||
$mode = $defaults['mode'];
|
||
}
|
||
|
||
return [
|
||
'primary_color' => $primary,
|
||
'secondary_color' => $secondary,
|
||
'background_color' => $background,
|
||
'surface_color' => $surface,
|
||
'font_family' => $bodyFont,
|
||
'heading_font' => $headingFont,
|
||
'body_font' => $bodyFont,
|
||
'font_size' => $fontSize,
|
||
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
||
'logo_mode' => $logoMode,
|
||
'logo_value' => $logoValue,
|
||
'logo_position' => $logoPosition,
|
||
'logo_size' => $logoSize,
|
||
'button_style' => $buttonStyle,
|
||
'button_radius' => $buttonRadius,
|
||
'button_primary_color' => $buttonPrimary,
|
||
'button_secondary_color' => $buttonSecondary,
|
||
'link_color' => $linkColor,
|
||
'mode' => $mode,
|
||
'use_default_branding' => $useDefault,
|
||
'palette' => [
|
||
'primary' => $primary,
|
||
'secondary' => $secondary,
|
||
'background' => $background,
|
||
'surface' => $surface,
|
||
],
|
||
'typography' => [
|
||
'heading' => $headingFont,
|
||
'body' => $bodyFont,
|
||
'size' => $fontSize,
|
||
],
|
||
'logo' => [
|
||
'mode' => $logoMode,
|
||
'value' => $logoValue,
|
||
'position' => $logoPosition,
|
||
'size' => $logoSize,
|
||
],
|
||
'buttons' => [
|
||
'style' => $buttonStyle,
|
||
'radius' => $buttonRadius,
|
||
'primary' => $buttonPrimary,
|
||
'secondary' => $buttonSecondary,
|
||
'link_color' => $linkColor,
|
||
],
|
||
'icon' => $logoMode === 'emoticon' ? $logoValue : ($event->eventType?->icon ?? null),
|
||
];
|
||
}
|
||
|
||
private function encodeGalleryCursor(Photo $photo): string
|
||
{
|
||
return base64_encode(json_encode([
|
||
'id' => $photo->id,
|
||
'created_at' => $photo->created_at?->toIso8601String(),
|
||
]));
|
||
}
|
||
|
||
private function decodeGalleryCursor(?string $cursor): ?array
|
||
{
|
||
if (! $cursor) {
|
||
return null;
|
||
}
|
||
|
||
$decoded = json_decode(base64_decode($cursor, true) ?: '', true);
|
||
|
||
if (! is_array($decoded) || ! isset($decoded['id'], $decoded['created_at'])) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
return [
|
||
'id' => (int) $decoded['id'],
|
||
'created_at' => Carbon::parse($decoded['created_at']),
|
||
];
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function makeGalleryPhotoResource(Photo $photo, string $token): array
|
||
{
|
||
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
|
||
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
|
||
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
|
||
|
||
return [
|
||
'id' => $photo->id,
|
||
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
|
||
'full_url' => $fullUrl,
|
||
'download_url' => $downloadUrl,
|
||
'likes_count' => $photo->likes_count,
|
||
'guest_name' => $photo->guest_name,
|
||
'created_at' => $photo->created_at?->toIso8601String(),
|
||
];
|
||
}
|
||
|
||
private function makeSignedGalleryAssetUrlForId(string $token, int $photoId, string $variant): ?string
|
||
{
|
||
if (! in_array($variant, ['thumbnail', 'full'], true)) {
|
||
return null;
|
||
}
|
||
|
||
return URL::temporarySignedRoute(
|
||
'api.v1.gallery.photos.asset',
|
||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||
[
|
||
'token' => $token,
|
||
'photo' => $photoId,
|
||
'variant' => $variant,
|
||
]
|
||
);
|
||
}
|
||
|
||
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
|
||
{
|
||
return $this->makeSignedGalleryAssetUrlForId($token, (int) $photo->id, $variant);
|
||
}
|
||
|
||
private function makeSignedBrandingUrl(?string $path): ?string
|
||
{
|
||
if (! $path) {
|
||
return null;
|
||
}
|
||
|
||
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
||
return $path;
|
||
}
|
||
|
||
$normalized = ltrim($path, '/');
|
||
if (str_starts_with($normalized, 'storage/')) {
|
||
$normalized = substr($normalized, strlen('storage/'));
|
||
}
|
||
|
||
if (! $this->isAllowedBrandingPath($normalized)) {
|
||
return null;
|
||
}
|
||
|
||
return URL::temporarySignedRoute(
|
||
'api.v1.branding.asset',
|
||
now()->addSeconds(self::BRANDING_SIGNED_TTL_SECONDS),
|
||
['path' => $normalized]
|
||
);
|
||
}
|
||
|
||
public function brandingAsset(Request $request, string $path)
|
||
{
|
||
$cleanPath = ltrim($path, '/');
|
||
|
||
if ($cleanPath === '' || str_contains($cleanPath, '..') || ! $this->isAllowedBrandingPath($cleanPath)) {
|
||
return ApiError::response(
|
||
'branding_not_found',
|
||
'Branding Asset Not Found',
|
||
'The requested branding asset could not be found.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['path' => $path]
|
||
);
|
||
}
|
||
|
||
$diskName = config('filesystems.default', 'public');
|
||
|
||
try {
|
||
$storage = Storage::disk($diskName);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Branding asset disk unavailable', [
|
||
'path' => $cleanPath,
|
||
'disk' => $diskName,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return ApiError::response(
|
||
'branding_not_found',
|
||
'Branding Asset Not Found',
|
||
'The requested branding asset could not be found.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['path' => $path]
|
||
);
|
||
}
|
||
|
||
if (! $storage->exists($cleanPath)) {
|
||
return ApiError::response(
|
||
'branding_not_found',
|
||
'Branding Asset Not Found',
|
||
'The requested branding asset could not be found.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['path' => $path]
|
||
);
|
||
}
|
||
|
||
try {
|
||
$stream = $storage->readStream($cleanPath);
|
||
if (! $stream) {
|
||
throw new \RuntimeException('Unable to read branding asset stream.');
|
||
}
|
||
|
||
$mime = $storage->mimeType($cleanPath) ?: 'application/octet-stream';
|
||
$size = null;
|
||
try {
|
||
$size = $storage->size($cleanPath);
|
||
} catch (\Throwable $e) {
|
||
$size = null;
|
||
}
|
||
|
||
$headers = [
|
||
'Content-Type' => $mime,
|
||
'Cache-Control' => 'private, max-age='.self::BRANDING_SIGNED_TTL_SECONDS,
|
||
];
|
||
|
||
if ($size) {
|
||
$headers['Content-Length'] = $size;
|
||
}
|
||
|
||
return response()->stream(function () use ($stream) {
|
||
fpassthru($stream);
|
||
fclose($stream);
|
||
}, 200, $headers);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Branding asset stream error', [
|
||
'path' => $cleanPath,
|
||
'disk' => $diskName,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return ApiError::response(
|
||
'branding_not_found',
|
||
'Branding Asset Not Found',
|
||
'The requested branding asset could not be loaded.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['path' => $path]
|
||
);
|
||
}
|
||
}
|
||
|
||
private function isAllowedBrandingPath(string $path): bool
|
||
{
|
||
if ($path === '' || str_contains($path, '..')) {
|
||
return false;
|
||
}
|
||
|
||
$allowedPrefixes = [
|
||
'branding/',
|
||
'events/',
|
||
'tenant-branding/',
|
||
];
|
||
|
||
foreach ($allowedPrefixes as $prefix) {
|
||
if (str_starts_with($path, $prefix)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||
{
|
||
return URL::temporarySignedRoute(
|
||
'api.v1.gallery.photos.download',
|
||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||
[
|
||
'token' => $token,
|
||
'photo' => $photo->id,
|
||
]
|
||
);
|
||
}
|
||
|
||
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||
{
|
||
return URL::temporarySignedRoute(
|
||
'api.v1.photo-shares.asset',
|
||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||
[
|
||
'slug' => $shareLink->slug,
|
||
'variant' => $variant,
|
||
],
|
||
absolute: false
|
||
);
|
||
}
|
||
|
||
public function gallery(Request $request, string $token)
|
||
{
|
||
$locale = $request->query('locale', app()->getLocale());
|
||
|
||
$resolved = $this->resolveGalleryEvent($request, $token);
|
||
|
||
if ($resolved instanceof JsonResponse) {
|
||
return $resolved;
|
||
}
|
||
|
||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||
[$event, $joinToken] = $resolved;
|
||
|
||
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
|
||
return $downloadResponse;
|
||
}
|
||
|
||
$branding = $this->buildGalleryBranding($event);
|
||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||
$settings = is_array($event->settings) ? $event->settings : [];
|
||
|
||
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);
|
||
$settings = $this->normalizeSettings($event->settings ?? []);
|
||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||
$event->loadMissing('photoboothSetting');
|
||
|
||
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->photoboothSetting?->enabled),
|
||
'branding' => $branding,
|
||
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'),
|
||
'engagement_mode' => $engagementMode,
|
||
])->header('Cache-Control', 'no-store');
|
||
}
|
||
|
||
public function package(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$eventRecord, $joinToken] = $result;
|
||
|
||
$event = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])
|
||
->findOrFail($eventRecord->id);
|
||
|
||
if (! $event->tenant) {
|
||
return ApiError::response(
|
||
'event_not_found',
|
||
'Event not accessible',
|
||
'The selected event is no longer available.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['scope' => 'photos', 'event_id' => $event->id]
|
||
);
|
||
}
|
||
|
||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
||
$event->tenant,
|
||
$event->id,
|
||
$event
|
||
);
|
||
|
||
if (! $eventPackage || ! $eventPackage->package) {
|
||
return response()->json([
|
||
'id' => null,
|
||
'event_id' => $event->id,
|
||
'package_id' => null,
|
||
'package' => null,
|
||
'used_photos' => (int) ($eventPackage?->used_photos ?? 0),
|
||
'used_guests' => (int) ($eventPackage?->used_guests ?? 0),
|
||
'expires_at' => $eventPackage?->gallery_expires_at?->toIso8601String(),
|
||
'limits' => null,
|
||
])->header('Cache-Control', 'no-store');
|
||
}
|
||
|
||
$package = $eventPackage->package;
|
||
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
|
||
$watermarkPolicy = $this->determineWatermarkPolicy($event);
|
||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||
$watermark = $this->resolveWatermarkConfig($event);
|
||
|
||
return response()->json([
|
||
'id' => $eventPackage->id,
|
||
'event_id' => $event->id,
|
||
'package_id' => $eventPackage->package_id,
|
||
'package' => [
|
||
'id' => $eventPackage->package_id,
|
||
'name' => $package?->getNameForLocale(app()->getLocale()) ?? $package?->name,
|
||
'max_photos' => $package?->max_photos,
|
||
'max_guests' => $package?->max_guests,
|
||
'gallery_days' => $package?->gallery_days,
|
||
'watermark_policy' => $watermarkPolicy,
|
||
'branding_allowed' => $brandingAllowed,
|
||
],
|
||
'used_photos' => (int) $eventPackage->used_photos,
|
||
'used_guests' => (int) $eventPackage->used_guests,
|
||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||
'limits' => $summary,
|
||
'watermark' => $watermark,
|
||
])->header('Cache-Control', 'no-store');
|
||
}
|
||
|
||
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
||
{
|
||
$preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);
|
||
|
||
foreach ($variantPreference as $variant) {
|
||
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals);
|
||
|
||
if (! $path) {
|
||
continue;
|
||
}
|
||
|
||
$diskName = $this->resolveStorageDisk($disk);
|
||
|
||
try {
|
||
$storage = Storage::disk($diskName);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Gallery asset disk unavailable', [
|
||
'event_id' => $event->id,
|
||
'photo_id' => $record->id,
|
||
'disk' => $diskName,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
continue;
|
||
}
|
||
|
||
foreach ($this->storagePathCandidates($path) as $candidate) {
|
||
try {
|
||
if (! $storage->exists($candidate)) {
|
||
continue;
|
||
}
|
||
|
||
$stream = $storage->readStream($candidate);
|
||
if (! $stream) {
|
||
continue;
|
||
}
|
||
|
||
$size = null;
|
||
try {
|
||
$size = $storage->size($candidate);
|
||
} catch (\Throwable $e) {
|
||
$size = null;
|
||
}
|
||
|
||
if (! $mime) {
|
||
try {
|
||
$mime = $storage->mimeType($candidate);
|
||
} catch (\Throwable $e) {
|
||
$mime = null;
|
||
}
|
||
}
|
||
|
||
$extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
|
||
$suffix = $variant === 'thumbnail' ? '-thumb' : '';
|
||
$filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');
|
||
|
||
$headers = [
|
||
'Content-Type' => $mime ?? 'image/jpeg',
|
||
'Cache-Control' => $disposition === 'attachment'
|
||
? 'no-store, max-age=0'
|
||
: 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
|
||
'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
|
||
];
|
||
|
||
if ($size) {
|
||
$headers['Content-Length'] = $size;
|
||
}
|
||
|
||
return response()->stream(function () use ($stream) {
|
||
fpassthru($stream);
|
||
fclose($stream);
|
||
}, 200, $headers);
|
||
} catch (\Throwable $e) {
|
||
Log::warning('Gallery asset stream error', [
|
||
'event_id' => $event->id,
|
||
'photo_id' => $record->id,
|
||
'disk' => $diskName,
|
||
'path' => $candidate,
|
||
'variant' => $variant,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return ApiError::response(
|
||
'photo_unavailable',
|
||
'Photo Unavailable',
|
||
'The requested photo could not be loaded.',
|
||
Response::HTTP_NOT_FOUND,
|
||
[
|
||
'photo_id' => $record->id,
|
||
'event_id' => $event->id,
|
||
]
|
||
);
|
||
}
|
||
|
||
private function resolvePhotoVariant(Photo $record, string $variant, bool $preferOriginals = false): array
|
||
{
|
||
if ($variant === 'thumbnail') {
|
||
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
|
||
$watermarked = $preferOriginals
|
||
? null
|
||
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_thumbnail')->first();
|
||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
||
} else {
|
||
$watermarked = $preferOriginals
|
||
? null
|
||
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked')->first();
|
||
$asset = $record->mediaAsset ?? $watermarked ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||
$path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null);
|
||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
||
}
|
||
|
||
return [
|
||
$disk ?: config('filesystems.default', 'public'),
|
||
$path,
|
||
$mime,
|
||
];
|
||
}
|
||
|
||
private function resolveStorageDisk(?string $disk): string
|
||
{
|
||
$disk = $disk ?: config('filesystems.default', 'public');
|
||
|
||
if (! config("filesystems.disks.{$disk}")) {
|
||
return config('filesystems.default', 'public');
|
||
}
|
||
|
||
return $disk;
|
||
}
|
||
|
||
private function storagePathCandidates(string $path): array
|
||
{
|
||
$normalized = str_replace('\\', '/', $path);
|
||
$candidates = [$normalized];
|
||
|
||
$trimmed = ltrim($normalized, '/');
|
||
if ($trimmed !== $normalized) {
|
||
$candidates[] = $trimmed;
|
||
}
|
||
|
||
if (str_starts_with($trimmed, 'storage/')) {
|
||
$candidates[] = substr($trimmed, strlen('storage/'));
|
||
}
|
||
|
||
if (str_starts_with($trimmed, 'public/')) {
|
||
$candidates[] = substr($trimmed, strlen('public/'));
|
||
}
|
||
|
||
$needle = '/storage/app/public/';
|
||
if (str_contains($normalized, $needle)) {
|
||
$candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
|
||
}
|
||
|
||
return array_values(array_unique(array_filter($candidates)));
|
||
}
|
||
|
||
public function notifications(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'tenant_id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event] = $result;
|
||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||
$limit = max(1, min(50, (int) $request->integer('limit', 35)));
|
||
$statusFilter = $request->string('status')->lower()->value();
|
||
$scopeFilter = $request->string('scope')->lower()->value();
|
||
|
||
if (! Schema::hasTable('guest_notifications')) {
|
||
return $this->emptyNotificationsResponse($request, $event->id, 'disabled');
|
||
}
|
||
|
||
$baseQuery = GuestNotification::query()
|
||
->where('event_id', $event->id)
|
||
->active()
|
||
->notExpired()
|
||
->visibleToGuest($guestIdentifier);
|
||
|
||
if ($statusFilter === 'unread') {
|
||
$baseQuery->where(function ($query) use ($guestIdentifier) {
|
||
$query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier))
|
||
->orWhereHas('receipts', fn ($receipt) => $receipt
|
||
->where('guest_identifier', $guestIdentifier)
|
||
->where('status', GuestNotificationDeliveryStatus::NEW->value));
|
||
});
|
||
} elseif ($statusFilter === 'read') {
|
||
$baseQuery->whereHas('receipts', fn ($receipt) => $receipt
|
||
->where('guest_identifier', $guestIdentifier)
|
||
->where('status', GuestNotificationDeliveryStatus::READ->value));
|
||
} elseif ($statusFilter === 'dismissed') {
|
||
$baseQuery->whereHas('receipts', fn ($receipt) => $receipt
|
||
->where('guest_identifier', $guestIdentifier)
|
||
->where('status', GuestNotificationDeliveryStatus::DISMISSED->value));
|
||
}
|
||
|
||
if ($scopeFilter === 'uploads') {
|
||
$baseQuery->whereIn('type', [GuestNotificationType::UPLOAD_ALERT->value, GuestNotificationType::PHOTO_ACTIVITY->value]);
|
||
} elseif ($scopeFilter === 'tips') {
|
||
$baseQuery->whereIn('type', [GuestNotificationType::SUPPORT_TIP->value, GuestNotificationType::ACHIEVEMENT_MAJOR->value]);
|
||
} elseif ($scopeFilter === 'general') {
|
||
$baseQuery->whereIn('type', [GuestNotificationType::BROADCAST->value, GuestNotificationType::FEEDBACK_REQUEST->value]);
|
||
}
|
||
|
||
$notifications = (clone $baseQuery)
|
||
->with(['receipts' => fn ($query) => $query->where('guest_identifier', $guestIdentifier)])
|
||
->orderByDesc('priority')
|
||
->orderByDesc('id')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
$unreadCount = (clone $baseQuery)
|
||
->where(function ($query) use ($guestIdentifier) {
|
||
$query->whereDoesntHave('receipts', fn ($receipt) => $receipt->where('guest_identifier', $guestIdentifier))
|
||
->orWhereHas('receipts', fn ($receipt) => $receipt
|
||
->where('guest_identifier', $guestIdentifier)
|
||
->where('status', GuestNotificationDeliveryStatus::NEW->value));
|
||
})
|
||
->count();
|
||
|
||
$data = $notifications->map(fn (GuestNotification $notification) => $this->formatGuestNotification($notification, $guestIdentifier));
|
||
|
||
$etag = sha1(json_encode([
|
||
$event->id,
|
||
$guestIdentifier,
|
||
$unreadCount,
|
||
$notifications->first()?->updated_at?->toAtomString(),
|
||
]));
|
||
|
||
$clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags());
|
||
if (in_array($etag, $clientEtags, true)) {
|
||
return response('', 304)
|
||
->header('ETag', $etag)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('Vary', 'X-Device-Id, Accept-Language');
|
||
}
|
||
|
||
return response()->json([
|
||
'data' => $data,
|
||
'meta' => [
|
||
'unread_count' => $unreadCount,
|
||
'poll_after_seconds' => 90,
|
||
],
|
||
])->header('ETag', $etag)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('Vary', 'X-Device-Id, Accept-Language');
|
||
}
|
||
|
||
private function emptyNotificationsResponse(Request $request, int $eventId, string $reason = 'empty'): JsonResponse
|
||
{
|
||
$etag = sha1(sprintf('event:%d:guest_notifications:%s', $eventId, $reason));
|
||
|
||
$clientEtags = array_map(fn ($tag) => trim($tag, '"'), $request->getETags());
|
||
if (in_array($etag, $clientEtags, true)) {
|
||
return response()->json([], Response::HTTP_NOT_MODIFIED)
|
||
->header('ETag', $etag)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('Vary', 'X-Device-Id, Accept-Language');
|
||
}
|
||
|
||
return response()->json([
|
||
'data' => [],
|
||
'meta' => [
|
||
'unread_count' => 0,
|
||
'poll_after_seconds' => 120,
|
||
'reason' => $reason,
|
||
],
|
||
])->header('ETag', $etag)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('Vary', 'X-Device-Id, Accept-Language');
|
||
}
|
||
|
||
public function registerPushSubscription(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$eventRecord] = $result;
|
||
$validated = $request->validate([
|
||
'endpoint' => ['required', 'url', 'max:500'],
|
||
'keys.p256dh' => ['required', 'string', 'max:255'],
|
||
'keys.auth' => ['required', 'string', 'max:255'],
|
||
'expiration_time' => ['nullable'],
|
||
'content_encoding' => ['nullable', 'string', 'max:32'],
|
||
]);
|
||
|
||
$event = Event::findOrFail($eventRecord->id);
|
||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||
$deviceId = $this->resolveDeviceIdentifier($request);
|
||
|
||
$payload = [
|
||
'endpoint' => $validated['endpoint'],
|
||
'keys' => [
|
||
'p256dh' => $validated['keys']['p256dh'],
|
||
'auth' => $validated['keys']['auth'],
|
||
],
|
||
'expiration_time' => $validated['expiration_time'] ?? null,
|
||
'content_encoding' => $validated['content_encoding'] ?? null,
|
||
'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'),
|
||
'user_agent' => (string) $request->userAgent(),
|
||
];
|
||
|
||
$subscription = $this->pushSubscriptions->register($event, $guestIdentifier, $deviceId, $payload);
|
||
|
||
return response()->json([
|
||
'id' => $subscription->id,
|
||
'status' => $subscription->status,
|
||
], Response::HTTP_CREATED);
|
||
}
|
||
|
||
public function destroyPushSubscription(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$eventRecord] = $result;
|
||
|
||
$validated = $request->validate([
|
||
'endpoint' => ['required', 'url', 'max:500'],
|
||
]);
|
||
|
||
$event = Event::findOrFail($eventRecord->id);
|
||
$revoked = $this->pushSubscriptions->revoke($event, $validated['endpoint']);
|
||
|
||
return response()->json([
|
||
'status' => $revoked ? 'revoked' : 'not_found',
|
||
]);
|
||
}
|
||
|
||
public function markNotificationRead(Request $request, string $token, GuestNotification $notification)
|
||
{
|
||
return $this->handleNotificationAction($request, $token, $notification, 'read');
|
||
}
|
||
|
||
public function dismissNotification(Request $request, string $token, GuestNotification $notification)
|
||
{
|
||
return $this->handleNotificationAction($request, $token, $notification, 'dismiss');
|
||
}
|
||
|
||
private function handleNotificationAction(Request $request, string $token, GuestNotification $notification, string $action)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event] = $result;
|
||
|
||
if ((int) $notification->event_id !== (int) $event->id) {
|
||
return ApiError::response(
|
||
'notification_not_found',
|
||
'Notification not found',
|
||
'Diese Benachrichtigung gehört nicht zu diesem Event.',
|
||
Response::HTTP_NOT_FOUND
|
||
);
|
||
}
|
||
|
||
$guestIdentifier = $this->resolveNotificationIdentifier($request);
|
||
|
||
if (! $this->notificationVisibleToGuest($notification, $guestIdentifier)) {
|
||
return ApiError::response(
|
||
'notification_forbidden',
|
||
'Notification unavailable',
|
||
'Diese Nachricht steht nicht zur Verfügung.',
|
||
Response::HTTP_FORBIDDEN
|
||
);
|
||
}
|
||
|
||
$receipt = $action === 'read'
|
||
? $this->guestNotificationService->markAsRead($notification, $guestIdentifier)
|
||
: $this->guestNotificationService->dismiss($notification, $guestIdentifier);
|
||
|
||
return response()->json([
|
||
'id' => $notification->id,
|
||
'status' => $receipt->status->value,
|
||
'read_at' => $receipt->read_at?->toAtomString(),
|
||
'dismissed_at' => $receipt->dismissed_at?->toAtomString(),
|
||
]);
|
||
}
|
||
|
||
private function notificationVisibleToGuest(GuestNotification $notification, string $guestIdentifier): bool
|
||
{
|
||
if ($notification->status !== GuestNotificationState::ACTIVE) {
|
||
return false;
|
||
}
|
||
|
||
if ($notification->hasExpired()) {
|
||
return false;
|
||
}
|
||
|
||
if ($notification->audience_scope === GuestNotificationAudience::ALL) {
|
||
return true;
|
||
}
|
||
|
||
return $notification->audience_scope === GuestNotificationAudience::GUEST
|
||
&& $notification->target_identifier === $guestIdentifier;
|
||
}
|
||
|
||
private function formatGuestNotification(GuestNotification $notification, string $guestIdentifier): array
|
||
{
|
||
$receipt = $notification->receipts->firstWhere('guest_identifier', $guestIdentifier);
|
||
$status = $receipt?->status ?? GuestNotificationDeliveryStatus::NEW;
|
||
$payload = $notification->payload ?? [];
|
||
|
||
$cta = null;
|
||
if (is_array($payload) && isset($payload['cta']) && is_array($payload['cta'])) {
|
||
$ctaPayload = $payload['cta'];
|
||
if (! empty($ctaPayload['label']) && ! empty($ctaPayload['href'])) {
|
||
$cta = [
|
||
'label' => (string) $ctaPayload['label'],
|
||
'href' => (string) $ctaPayload['href'],
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'id' => (int) $notification->id,
|
||
'type' => $notification->type->value,
|
||
'title' => $notification->title,
|
||
'body' => $notification->body,
|
||
'status' => $status->value,
|
||
'created_at' => $notification->created_at?->toAtomString(),
|
||
'read_at' => $receipt?->read_at?->toAtomString(),
|
||
'dismissed_at' => $receipt?->dismissed_at?->toAtomString(),
|
||
'cta' => $cta,
|
||
'payload' => $payload,
|
||
];
|
||
}
|
||
|
||
private function notifyDeviceUploadLimit(Event $event, string $guestIdentifier, string $token): 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', 'settings']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event, $joinToken] = $result;
|
||
$eventId = $event->id;
|
||
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
|
||
$settings = $this->normalizeSettings($event->settings ?? null);
|
||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||
|
||
// Approximate online guests as distinct recent uploaders in last 10 minutes.
|
||
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
|
||
$onlineGuests = DB::table('photos')
|
||
->where('event_id', $eventId)
|
||
->where('created_at', '>=', $tenMinutesAgo)
|
||
->distinct('guest_name')
|
||
->count('guest_name');
|
||
|
||
// Tasks solved as number of photos linked to a task (proxy metric).
|
||
$tasksSolved = $engagementMode === 'photo_only'
|
||
? 0
|
||
: DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count();
|
||
|
||
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
|
||
|
||
$payload = [
|
||
'online_guests' => $onlineGuests,
|
||
'tasks_solved' => $tasksSolved,
|
||
'latest_photo_at' => $latestPhotoAt,
|
||
'engagement_mode' => $engagementMode,
|
||
];
|
||
|
||
$etag = sha1(json_encode($payload));
|
||
$reqEtag = $request->headers->get('If-None-Match');
|
||
if ($reqEtag && $reqEtag === $etag) {
|
||
return response('', 304);
|
||
}
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('ETag', $etag);
|
||
}
|
||
|
||
public function emotions(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event] = $result;
|
||
$eventId = (int) $event->id;
|
||
|
||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||
|
||
$cacheKey = sprintf('event:%d:emotions:%s', $eventId, $locale);
|
||
|
||
$cached = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($eventId, $fallbacks) {
|
||
$rows = DB::table('emotions')
|
||
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
|
||
->join('event_types', 'emotion_event_type.event_type_id', '=', 'event_types.id')
|
||
->join('events', 'events.event_type_id', '=', 'event_types.id')
|
||
->where('events.id', $eventId)
|
||
->select([
|
||
'emotions.id',
|
||
'emotions.name',
|
||
'emotions.icon as emoji',
|
||
'emotions.description',
|
||
])
|
||
->orderBy('emotions.sort_order')
|
||
->get();
|
||
|
||
$data = $rows->map(function ($row) use ($fallbacks) {
|
||
return [
|
||
'id' => (int) $row->id,
|
||
'slug' => 'emotion-'.$row->id,
|
||
'name' => $this->firstLocalizedValue($row->name, $fallbacks, 'Emotion'),
|
||
'emoji' => $row->emoji,
|
||
'description' => $this->firstLocalizedValue($row->description, $fallbacks, ''),
|
||
];
|
||
})->values()->all();
|
||
|
||
return [
|
||
'data' => $data,
|
||
'hash' => sha1(json_encode($data)),
|
||
];
|
||
});
|
||
|
||
$payload = $cached['data'];
|
||
$etag = $cached['hash'];
|
||
$reqEtag = $request->headers->get('If-None-Match');
|
||
if ($reqEtag && $reqEtag === $etag) {
|
||
return response('', 304)
|
||
->header('Cache-Control', 'public, max-age=300')
|
||
->header('ETag', $etag)
|
||
->header('Vary', 'Accept-Language, X-Locale')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'public, max-age=300')
|
||
->header('ETag', $etag)
|
||
->header('Vary', 'Accept-Language, X-Locale')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
public function tasks(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale', 'settings']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event, $joinToken] = $result;
|
||
|
||
$settings = $this->normalizeSettings($event->settings ?? null);
|
||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||
|
||
[$resolvedLocale] = $this->resolveGuestLocale($request, $event);
|
||
|
||
$page = max(1, (int) $request->query('page', 1));
|
||
$perPage = max(1, min(100, (int) $request->query('per_page', 20)));
|
||
|
||
if ($engagementMode === 'photo_only') {
|
||
$payload = [
|
||
'data' => [],
|
||
'meta' => [
|
||
'total' => 0,
|
||
'per_page' => $perPage,
|
||
'current_page' => $page,
|
||
'last_page' => 1,
|
||
'has_more' => false,
|
||
'seed' => null,
|
||
],
|
||
'engagement_mode' => $engagementMode,
|
||
];
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'public, max-age=120')
|
||
->header('Vary', 'Accept-Language, X-Locale')
|
||
->header('X-Content-Locale', $resolvedLocale)
|
||
->header('X-Engagement-Mode', $engagementMode);
|
||
}
|
||
|
||
$cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) {
|
||
return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null);
|
||
});
|
||
|
||
$tasks = $cached['tasks'];
|
||
$baseHash = $cached['hash'] ?? sha1(json_encode($tasks));
|
||
|
||
// Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed.
|
||
$seedParam = $request->query('seed');
|
||
$guestIdentifier = $this->determineGuestIdentifier($request);
|
||
$seedValue = is_numeric($seedParam)
|
||
? (int) $seedParam
|
||
: $this->buildDeterministicSeed(($guestIdentifier ? $guestIdentifier.'|' : '').(string) $event->id);
|
||
$randomizer = new \Random\Randomizer(new \Random\Engine\Mt19937($seedValue));
|
||
$shuffled = $randomizer->shuffleArray($tasks);
|
||
|
||
$total = count($shuffled);
|
||
$offset = ($page - 1) * $perPage;
|
||
$data = array_slice($shuffled, $offset, $perPage);
|
||
$lastPage = (int) ceil($total / $perPage);
|
||
$hasMore = $page < $lastPage;
|
||
|
||
$etag = sha1($baseHash.':'.$page.':'.$perPage.':'.$seedValue);
|
||
$reqEtag = $request->headers->get('If-None-Match');
|
||
|
||
if ($reqEtag && $reqEtag === $etag) {
|
||
return response('', 304)
|
||
->header('Cache-Control', 'public, max-age=120')
|
||
->header('Vary', 'Accept-Language, X-Locale')
|
||
->header('X-Content-Locale', $resolvedLocale)
|
||
->header('ETag', $etag);
|
||
}
|
||
|
||
$payload = [
|
||
'data' => $data,
|
||
'meta' => [
|
||
'total' => $total,
|
||
'per_page' => $perPage,
|
||
'current_page' => $page,
|
||
'last_page' => $lastPage,
|
||
'has_more' => $hasMore,
|
||
'seed' => $seedValue,
|
||
],
|
||
];
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'public, max-age=120')
|
||
->header('ETag', $etag)
|
||
->header('Vary', 'Accept-Language, X-Locale')
|
||
->header('X-Content-Locale', $resolvedLocale);
|
||
}
|
||
|
||
public function photos(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event] = $result;
|
||
$eventId = (int) $event->id;
|
||
|
||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||
|
||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||
$filter = $request->query('filter');
|
||
|
||
$since = $request->query('since');
|
||
$query = DB::table('photos')
|
||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||
->select([
|
||
'photos.id',
|
||
'photos.file_path',
|
||
'photos.thumbnail_path',
|
||
'photos.likes_count',
|
||
'photos.emotion_id',
|
||
'photos.task_id',
|
||
'photos.guest_name',
|
||
'photos.created_at',
|
||
'photos.ingest_source',
|
||
'tasks.title as task_title',
|
||
])
|
||
->where('photos.event_id', $eventId)
|
||
->where('photos.status', 'approved')
|
||
->orderByDesc('photos.created_at')
|
||
->limit(60);
|
||
|
||
// MyPhotos filter
|
||
if ($filter === 'photobooth') {
|
||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||
$query->where('guest_name', $deviceId);
|
||
}
|
||
|
||
if ($since) {
|
||
$query->where('photos.created_at', '>', $since);
|
||
}
|
||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
||
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
||
|
||
// Localize task title if present
|
||
if ($r->task_title) {
|
||
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||
}
|
||
|
||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
||
|
||
return $r;
|
||
});
|
||
$latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
|
||
$payload = [
|
||
'data' => $rows,
|
||
'latest_photo_at' => $latestPhotoAt,
|
||
];
|
||
$etag = sha1(json_encode([$since, $filter, $deviceId, $latestPhotoAt, $locale]));
|
||
$reqEtag = $request->headers->get('If-None-Match');
|
||
if ($reqEtag && $reqEtag === $etag) {
|
||
return response('', 304)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('ETag', $etag)
|
||
->header('Last-Modified', (string) $latestPhotoAt)
|
||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'no-store')
|
||
->header('ETag', $etag)
|
||
->header('Last-Modified', (string) $latestPhotoAt)
|
||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
public function photo(Request $request, int $id)
|
||
{
|
||
return ApiError::response(
|
||
'photo_not_found',
|
||
'Photo Not Found',
|
||
'Photo not found or event not public.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['photo_id' => $id]
|
||
);
|
||
}
|
||
|
||
public function like(Request $request, int $id)
|
||
{
|
||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
|
||
if ($deviceId === '') {
|
||
$deviceId = 'anon';
|
||
}
|
||
|
||
$photo = DB::table('photos')
|
||
->join('events', 'photos.event_id', '=', 'events.id')
|
||
->where('photos.id', $id)
|
||
->where('events.status', 'published')
|
||
->first(['photos.id', 'photos.event_id']);
|
||
if (! $photo) {
|
||
return ApiError::response(
|
||
'photo_not_found',
|
||
'Photo Not Found',
|
||
'Photo not found or event not public.',
|
||
Response::HTTP_NOT_FOUND,
|
||
['photo_id' => $id]
|
||
);
|
||
}
|
||
|
||
// Idempotent like per device
|
||
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
|
||
if ($exists) {
|
||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||
|
||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
||
}
|
||
|
||
DB::beginTransaction();
|
||
try {
|
||
DB::table('photo_likes')->insert([
|
||
'photo_id' => $id,
|
||
'guest_name' => $deviceId,
|
||
'ip_address' => 'device',
|
||
'created_at' => now(),
|
||
]);
|
||
DB::table('photos')->where('id', $id)->update([
|
||
'likes_count' => DB::raw('likes_count + 1'),
|
||
'updated_at' => now(),
|
||
]);
|
||
DB::commit();
|
||
} catch (\Throwable $e) {
|
||
DB::rollBack();
|
||
Log::warning('like failed', ['error' => $e->getMessage()]);
|
||
}
|
||
|
||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||
|
||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
||
}
|
||
|
||
public function upload(Request $request, string $token)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event, $joinToken] = $result;
|
||
$eventId = $event->id;
|
||
|
||
$eventModel = Event::with([
|
||
'tenant',
|
||
'eventPackage.package',
|
||
'eventPackages.package',
|
||
'storageAssignments.storageTarget',
|
||
])->findOrFail($eventId);
|
||
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', 'review');
|
||
$autoApproveUploads = $uploadVisibility === 'immediate';
|
||
|
||
$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', 'mimes:jpeg,jpg,png,webp,heic,heif,gif', 'max:12288'], // 12 MB, block SVG/other image types
|
||
'emotion_id' => ['nullable', 'integer'],
|
||
'emotion_slug' => ['nullable', 'string'],
|
||
'task_id' => ['nullable', 'integer'],
|
||
'guest_name' => ['nullable', 'string', 'max:255'],
|
||
]);
|
||
|
||
$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,
|
||
'tenant_id' => $tenantModel->id,
|
||
'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' => $autoApproveUploads ? 'approved' : 'pending',
|
||
|
||
// 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]);
|
||
|
||
ProcessPhotoSecurityScan::dispatch($photoId);
|
||
|
||
if ($eventPackage) {
|
||
$previousUsed = (int) $eventPackage->used_photos;
|
||
$eventPackage->increment('used_photos');
|
||
$eventPackage->refresh();
|
||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
|
||
}
|
||
|
||
$message = $autoApproveUploads
|
||
? 'Photo uploaded and visible.'
|
||
: 'Photo uploaded and pending review.';
|
||
|
||
$response = response()->json([
|
||
'id' => $photoId,
|
||
'status' => $autoApproveUploads ? 'approved' : 'pending',
|
||
'message' => $message,
|
||
], 201);
|
||
|
||
$this->recordTokenEvent(
|
||
$joinToken,
|
||
$request,
|
||
'upload_completed',
|
||
[
|
||
'event_id' => $eventId,
|
||
'photo_id' => $photoId,
|
||
'device_id' => $deviceId,
|
||
],
|
||
$token,
|
||
Response::HTTP_CREATED
|
||
);
|
||
|
||
event(new GuestPhotoUploaded(
|
||
$eventModel,
|
||
$photoId,
|
||
$deviceId,
|
||
$validated['guest_name'] ?? null,
|
||
));
|
||
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* Resolve emotion_id from validation data, supporting both direct ID and slug lookup
|
||
*/
|
||
private function resolveEmotionId(array $validated, int $eventId): int
|
||
{
|
||
// 1. Use explicit emotion_id if provided
|
||
if (isset($validated['emotion_id']) && $validated['emotion_id'] !== null) {
|
||
return (int) $validated['emotion_id'];
|
||
}
|
||
|
||
// 2. Resolve from emotion_slug if provided (format: 'emotion-{id}')
|
||
if (isset($validated['emotion_slug']) && $validated['emotion_slug']) {
|
||
$slug = $validated['emotion_slug'];
|
||
if (str_starts_with($slug, 'emotion-')) {
|
||
$id = (int) substr($slug, 8); // Remove 'emotion-' prefix
|
||
if ($id > 0) {
|
||
// Verify emotion exists and is available for this event
|
||
$exists = DB::table('emotions')
|
||
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
|
||
->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
|
||
->where('emotions.id', $id)
|
||
->where('events.id', $eventId)
|
||
->exists();
|
||
|
||
if ($exists) {
|
||
return $id;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Fallback: Get first available emotion for this event type
|
||
$defaultEmotion = DB::table('emotions')
|
||
->join('emotion_event_type', 'emotions.id', '=', 'emotion_event_type.emotion_id')
|
||
->join('events', 'emotion_event_type.event_type_id', '=', 'events.event_type_id')
|
||
->where('events.id', $eventId)
|
||
->orderBy('emotions.sort_order')
|
||
->value('emotions.id');
|
||
|
||
return $defaultEmotion ?: 1; // Ultimate fallback to emotion ID 1 (assuming "Happy" exists)
|
||
}
|
||
|
||
public function achievements(Request $request, string $identifier)
|
||
{
|
||
$result = $this->resolvePublishedEvent($request, $identifier, ['id', 'default_locale']);
|
||
|
||
if ($result instanceof JsonResponse) {
|
||
return $result;
|
||
}
|
||
|
||
[$event] = $result;
|
||
$eventId = (int) $event->id;
|
||
|
||
[$locale] = $this->resolveGuestLocale($request, $event);
|
||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||
|
||
$guestIdentifier = $this->determineGuestIdentifier($request);
|
||
|
||
$cacheKey = sprintf(
|
||
'event:%d:achievements:%s:%s',
|
||
$eventId,
|
||
$locale,
|
||
$guestIdentifier ? sha1($guestIdentifier) : 'public'
|
||
);
|
||
|
||
$cached = Cache::remember($cacheKey, now()->addSeconds(60), function () use ($eventId, $identifier, $guestIdentifier, $fallbacks) {
|
||
$payload = $this->buildAchievementsPayload($eventId, $identifier, $guestIdentifier, $fallbacks);
|
||
|
||
return [
|
||
'payload' => $payload,
|
||
'hash' => sha1(json_encode($payload)),
|
||
];
|
||
});
|
||
|
||
$payload = $cached['payload'];
|
||
$etag = $cached['hash'];
|
||
$ifNoneMatch = $request->headers->get('If-None-Match');
|
||
|
||
if ($ifNoneMatch && $ifNoneMatch === $etag) {
|
||
return response('', 304)
|
||
->header('Cache-Control', 'public, max-age=60')
|
||
->header('ETag', $etag)
|
||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
return response()->json($payload)
|
||
->header('Cache-Control', 'public, max-age=60')
|
||
->header('ETag', $etag)
|
||
->header('Vary', 'Accept-Language, X-Locale, X-Device-Id')
|
||
->header('X-Content-Locale', $locale);
|
||
}
|
||
|
||
private function resolveDiskUrl(string $disk, string $path): string
|
||
{
|
||
try {
|
||
return Storage::disk($disk)->url($path);
|
||
} catch (\Throwable $e) {
|
||
Log::debug('Falling back to raw path for storage URL', [
|
||
'disk' => $disk,
|
||
'path' => $path,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
|
||
return $path;
|
||
}
|
||
}
|
||
}
|