Fix tenant event form package selector so it no longer renders empty-value options, handles loading/empty
states, and pulls data from the authenticated /api/v1/tenant/packages endpoint.
(resources/js/admin/pages/EventFormPage.tsx, resources/js/admin/api.ts)
- Harden tenant-admin auth flow: prevent PKCE state loss, scope out StrictMode double-processing, add SPA
routes for /event-admin/login and /event-admin/logout, and tighten token/session clearing semantics (resources/js/admin/auth/{context,tokens}.tsx, resources/js/admin/pages/{AuthCallbackPage,LogoutPage}.tsx,
resources/js/admin/router.tsx, routes/web.php)
This commit is contained in:
@@ -10,9 +10,11 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use App\Support\ImageHelper;
|
||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Models\Event;
|
||||
@@ -24,9 +26,12 @@ use App\Models\EventMediaAsset;
|
||||
|
||||
class EventPublicController extends BaseController
|
||||
{
|
||||
private const SIGNED_URL_TTL_SECONDS = 1800;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventJoinTokenService $joinTokenService,
|
||||
private readonly EventStorageManager $eventStorageManager,
|
||||
private readonly JoinTokenAnalyticsRecorder $analyticsRecorder,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,34 +45,65 @@ class EventPublicController extends BaseController
|
||||
$joinToken = $this->joinTokenService->findToken($token, true);
|
||||
|
||||
if (! $joinToken) {
|
||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
|
||||
'token' => Str::limit($token, 12),
|
||||
]);
|
||||
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),
|
||||
]);
|
||||
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(),
|
||||
]);
|
||||
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,
|
||||
]);
|
||||
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']));
|
||||
@@ -77,10 +113,18 @@ class EventPublicController extends BaseController
|
||||
->first($columns);
|
||||
|
||||
if (! $event) {
|
||||
return $this->handleTokenFailure($request, $rateLimiterKey, 'invalid_token', Response::HTTP_NOT_FOUND, [
|
||||
'token' => Str::limit($token, 12),
|
||||
'reason' => 'event_missing',
|
||||
]);
|
||||
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') {
|
||||
@@ -90,6 +134,18 @@ class EventPublicController extends BaseController
|
||||
'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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'event_not_public',
|
||||
@@ -104,6 +160,22 @@ class EventPublicController extends BaseController
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -134,6 +206,18 @@ class EventPublicController extends BaseController
|
||||
$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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'gallery_expired',
|
||||
@@ -143,16 +227,47 @@ class EventPublicController extends BaseController
|
||||
], Response::HTTP_GONE);
|
||||
}
|
||||
|
||||
$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 = []): JsonResponse
|
||||
private function handleTokenFailure(
|
||||
Request $request,
|
||||
string $rateLimiterKey,
|
||||
string $code,
|
||||
int $status,
|
||||
array $context = [],
|
||||
?string $rawToken = null,
|
||||
?EventJoinToken $joinToken = null
|
||||
): JsonResponse
|
||||
{
|
||||
if (RateLimiter::tooManyAttempts($rateLimiterKey, 10)) {
|
||||
$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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'token_rate_limited',
|
||||
@@ -161,13 +276,22 @@ class EventPublicController extends BaseController
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
RateLimiter::hit($rateLimiterKey, 300);
|
||||
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 response()->json([
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
@@ -186,6 +310,89 @@ class EventPublicController extends BaseController
|
||||
};
|
||||
}
|
||||
|
||||
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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'access_rate_limited',
|
||||
'message' => 'Too many requests. Please slow down.',
|
||||
],
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'download_rate_limited',
|
||||
'message' => 'Download rate limit exceeded. Please wait a moment.',
|
||||
],
|
||||
], Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -288,23 +495,50 @@ class EventPublicController extends BaseController
|
||||
|
||||
private function makeGalleryPhotoResource(Photo $photo, string $token): array
|
||||
{
|
||||
$thumbnail = $this->toPublicUrl($photo->thumbnail_path ?? null) ?? $this->toPublicUrl($photo->file_path ?? null);
|
||||
$full = $this->toPublicUrl($photo->file_path ?? null);
|
||||
$thumbnailUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'thumbnail');
|
||||
$fullUrl = $this->makeSignedGalleryAssetUrl($token, $photo, 'full');
|
||||
$downloadUrl = $this->makeSignedGalleryDownloadUrl($token, $photo);
|
||||
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'thumbnail_url' => $thumbnail,
|
||||
'full_url' => $full,
|
||||
'download_url' => route('api.v1.gallery.photos.download', [
|
||||
'token' => $token,
|
||||
'photo' => $photo->id,
|
||||
]),
|
||||
'thumbnail_url' => $thumbnailUrl ?? $fullUrl,
|
||||
'full_url' => $fullUrl,
|
||||
'download_url' => $downloadUrl,
|
||||
'likes_count' => $photo->likes_count,
|
||||
'guest_name' => $photo->guest_name,
|
||||
'created_at' => $photo->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
private function makeSignedGalleryAssetUrl(string $token, Photo $photo, string $variant): ?string
|
||||
{
|
||||
if (! in_array($variant, ['thumbnail', 'full'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return URL::temporarySignedRoute(
|
||||
'api.v1.gallery.photos.asset',
|
||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||
[
|
||||
'token' => $token,
|
||||
'photo' => $photo->id,
|
||||
'variant' => $variant,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||||
{
|
||||
return URL::temporarySignedRoute(
|
||||
'api.v1.gallery.photos.download',
|
||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||
[
|
||||
'token' => $token,
|
||||
'photo' => $photo->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function gallery(Request $request, string $token)
|
||||
{
|
||||
$locale = $request->query('locale', app()->getLocale());
|
||||
@@ -316,7 +550,11 @@ class EventPublicController extends BaseController
|
||||
}
|
||||
|
||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||||
[$event] = $resolved;
|
||||
[$event, $joinToken] = $resolved;
|
||||
|
||||
if ($downloadResponse = $this->enforceDownloadThrottle($joinToken, $request, $token)) {
|
||||
return $downloadResponse;
|
||||
}
|
||||
|
||||
$branding = $this->buildGalleryBranding($event);
|
||||
$expiresAt = optional($event->eventPackage)->gallery_expires_at;
|
||||
@@ -342,7 +580,11 @@ class EventPublicController extends BaseController
|
||||
}
|
||||
|
||||
/** @var array{0: Event, 1: EventJoinToken} $resolved */
|
||||
[$event] = $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));
|
||||
@@ -389,6 +631,39 @@ class EventPublicController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
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 response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_not_found',
|
||||
'message' => 'The requested photo is no longer available.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$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);
|
||||
@@ -415,52 +690,7 @@ class EventPublicController extends BaseController
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
||||
|
||||
if ($asset) {
|
||||
$disk = $asset->disk ?? config('filesystems.default');
|
||||
$path = $asset->path ?? $record->file_path;
|
||||
|
||||
try {
|
||||
if ($path && Storage::disk($disk)->exists($path)) {
|
||||
$stream = Storage::disk($disk)->readStream($path);
|
||||
|
||||
if ($stream) {
|
||||
$extension = pathinfo($path, PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$filename = sprintf('fotospiel-event-%s-photo-%s.%s', $event->id, $record->id, $extension);
|
||||
$mime = $asset->mime_type ?? 'image/jpeg';
|
||||
|
||||
return response()->streamDownload(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}, $filename, [
|
||||
'Content-Type' => $mime,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gallery photo download failed', [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $record->id,
|
||||
'disk' => $asset->disk ?? null,
|
||||
'path' => $asset->path ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$publicUrl = $this->toPublicUrl($record->file_path ?? null);
|
||||
|
||||
if ($publicUrl) {
|
||||
return redirect()->away($publicUrl);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_unavailable',
|
||||
'message' => 'The requested photo could not be downloaded.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||
}
|
||||
|
||||
public function event(Request $request, string $token)
|
||||
@@ -518,6 +748,160 @@ class EventPublicController extends BaseController
|
||||
])->header('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
||||
{
|
||||
foreach ($variantPreference as $variant) {
|
||||
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
|
||||
|
||||
if (! $path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diskName = $this->resolveStorageDisk($disk);
|
||||
|
||||
try {
|
||||
$storage = Storage::disk($diskName);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gallery asset disk unavailable', [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $record->id,
|
||||
'disk' => $diskName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->storagePathCandidates($path) as $candidate) {
|
||||
try {
|
||||
if (! $storage->exists($candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stream = $storage->readStream($candidate);
|
||||
if (! $stream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$size = null;
|
||||
try {
|
||||
$size = $storage->size($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
$size = null;
|
||||
}
|
||||
|
||||
if (! $mime) {
|
||||
try {
|
||||
$mime = $storage->mimeType($candidate);
|
||||
} catch (\Throwable $e) {
|
||||
$mime = null;
|
||||
}
|
||||
}
|
||||
|
||||
$extension = pathinfo($candidate, PATHINFO_EXTENSION) ?: ($record->mime_type ? explode('/', $record->mime_type)[1] ?? 'jpg' : 'jpg');
|
||||
$suffix = $variant === 'thumbnail' ? '-thumb' : '';
|
||||
$filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $record->id, $suffix, $extension ?: 'jpg');
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => $mime ?? 'image/jpeg',
|
||||
'Cache-Control' => $disposition === 'attachment'
|
||||
? 'no-store, max-age=0'
|
||||
: 'private, max-age='.self::SIGNED_URL_TTL_SECONDS,
|
||||
'Content-Disposition' => ($disposition === 'attachment' ? 'attachment' : 'inline').'; filename="'.$filename.'"',
|
||||
];
|
||||
|
||||
if ($size) {
|
||||
$headers['Content-Length'] = $size;
|
||||
}
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
}, 200, $headers);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Gallery asset stream error', [
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $record->id,
|
||||
'disk' => $diskName,
|
||||
'path' => $candidate,
|
||||
'variant' => $variant,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fallbackUrl = $this->toPublicUrl($record->file_path ?? null);
|
||||
|
||||
if ($fallbackUrl) {
|
||||
return redirect()->away($fallbackUrl);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => [
|
||||
'code' => 'photo_unavailable',
|
||||
'message' => 'The requested photo could not be loaded.',
|
||||
],
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
private function resolvePhotoVariant(Photo $record, string $variant): array
|
||||
{
|
||||
if ($variant === 'thumbnail') {
|
||||
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
|
||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||
$mime = $asset?->mime_type ?? 'image/jpeg';
|
||||
} else {
|
||||
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||
$path = $asset?->path ?? ($record->file_path ?? null);
|
||||
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
||||
}
|
||||
|
||||
return [
|
||||
$disk ?: config('filesystems.default', 'public'),
|
||||
$path,
|
||||
$mime,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveStorageDisk(?string $disk): string
|
||||
{
|
||||
$disk = $disk ?: config('filesystems.default', 'public');
|
||||
|
||||
if (! config("filesystems.disks.{$disk}")) {
|
||||
return config('filesystems.default', 'public');
|
||||
}
|
||||
|
||||
return $disk;
|
||||
}
|
||||
|
||||
private function storagePathCandidates(string $path): array
|
||||
{
|
||||
$normalized = str_replace('\\', '/', $path);
|
||||
$candidates = [$normalized];
|
||||
|
||||
$trimmed = ltrim($normalized, '/');
|
||||
if ($trimmed !== $normalized) {
|
||||
$candidates[] = $trimmed;
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'storage/')) {
|
||||
$candidates[] = substr($trimmed, strlen('storage/'));
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'public/')) {
|
||||
$candidates[] = substr($trimmed, strlen('public/'));
|
||||
}
|
||||
|
||||
$needle = '/storage/app/public/';
|
||||
if (str_contains($normalized, $needle)) {
|
||||
$candidates[] = substr($normalized, strpos($normalized, $needle) + strlen($needle));
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($candidates)));
|
||||
}
|
||||
|
||||
public function stats(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
@@ -526,7 +910,7 @@ class EventPublicController extends BaseController
|
||||
return $result;
|
||||
}
|
||||
|
||||
[$event] = $result;
|
||||
[$event, $joinToken] = $result;
|
||||
$eventId = $event->id;
|
||||
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId);
|
||||
|
||||
@@ -866,6 +1250,19 @@ class EventPublicController extends BaseController
|
||||
// Per-device cap per event (MVP: 50)
|
||||
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
||||
if ($deviceCount >= 50) {
|
||||
$this->recordTokenEvent(
|
||||
$joinToken,
|
||||
$request,
|
||||
'upload_device_limit',
|
||||
[
|
||||
'event_id' => $eventId,
|
||||
'device_id' => $deviceId,
|
||||
'device_count' => $deviceCount,
|
||||
],
|
||||
$token,
|
||||
Response::HTTP_TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
return response()->json(['error' => ['code' => 'limit_reached', 'message' => 'Upload-Limit erreicht']], 429);
|
||||
}
|
||||
|
||||
@@ -936,11 +1333,26 @@ class EventPublicController extends BaseController
|
||||
->where('id', $photoId)
|
||||
->update(['media_asset_id' => $asset->id]);
|
||||
|
||||
return response()->json([
|
||||
$response = response()->json([
|
||||
'id' => $photoId,
|
||||
'file_path' => $url,
|
||||
'thumbnail_path' => $thumbUrl,
|
||||
], 201);
|
||||
|
||||
$this->recordTokenEvent(
|
||||
$joinToken,
|
||||
$request,
|
||||
'upload_completed',
|
||||
[
|
||||
'event_id' => $eventId,
|
||||
'photo_id' => $photoId,
|
||||
'device_id' => $deviceId,
|
||||
],
|
||||
$token,
|
||||
Response::HTTP_CREATED
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1201,7 +1613,6 @@ class EventPublicController extends BaseController
|
||||
->header('Cache-Control', 'no-store')
|
||||
->header('ETag', $etag);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveDiskUrl(string $disk, string $path): string
|
||||
{
|
||||
@@ -1217,3 +1628,5 @@ class EventPublicController extends BaseController
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user