From 0aae4949457d04e46f0ddb1dc4d205c040a8a21f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 16 Dec 2025 16:19:23 +0100 Subject: [PATCH] photo-upload und ansicht im admin gefixt --- .../Controllers/Api/EventPublicController.php | 1 + .../Api/Tenant/PhotoController.php | 165 ++++++++++++++++++ app/Http/Resources/Tenant/PhotoResource.php | 8 +- routes/api.php | 6 + 4 files changed, 174 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 47a8d22..8245340 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2789,6 +2789,7 @@ class EventPublicController extends BaseController $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, diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 6bab1a7..fde946b 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -23,6 +23,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\Response; class PhotoController extends Controller @@ -136,6 +137,117 @@ class PhotoController extends Controller ]); } + public function asset(Request $request, Event $event, Photo $photo, string $variant): StreamedResponse|JsonResponse + { + if ($photo->event_id !== $event->id) { + return ApiError::response( + 'photo_not_found', + 'Foto nicht gefunden', + 'Das Foto gehört nicht zu diesem Event.', + Response::HTTP_NOT_FOUND, + ['photo_id' => $photo->id] + ); + } + + $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false); + [$disk, $path, $mime] = $this->resolvePhotoVariant($photo, $variant, $preferOriginals); + + if (! $path) { + return ApiError::response( + 'photo_unavailable', + 'Photo Unavailable', + 'The requested photo could not be loaded.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo->id, + 'event_id' => $event->id, + ] + ); + } + + if (Str::startsWith($path, ['http://', 'https://'])) { + return redirect()->away($path); + } + + try { + $storage = Storage::disk($disk); + } catch (\Throwable $e) { + Log::warning('Tenant photo asset disk unavailable', [ + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'disk' => $disk, + 'error' => $e->getMessage(), + ]); + + return ApiError::response( + 'photo_unavailable', + 'Photo Unavailable', + 'The requested photo could not be loaded.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo->id, + 'event_id' => $event->id, + ] + ); + } + + foreach ($this->storagePathCandidates($path) as $candidate) { + 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) ?: ($photo->mime_type ? explode('/', $photo->mime_type)[1] ?? 'jpg' : 'jpg'); + $suffix = $variant === 'thumbnail' ? '-thumb' : ''; + $filename = sprintf('fotospiel-event-%s-photo-%s%s.%s', $event->id, $photo->id, $suffix, $extension ?: 'jpg'); + + $headers = [ + 'Content-Type' => $mime ?? 'image/jpeg', + 'Cache-Control' => 'private, max-age=1800', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', + ]; + + if ($size) { + $headers['Content-Length'] = $size; + } + + return response()->stream(function () use ($stream) { + fpassthru($stream); + fclose($stream); + }, 200, $headers); + } + + return ApiError::response( + 'photo_unavailable', + 'Photo Unavailable', + 'The requested photo could not be loaded.', + Response::HTTP_NOT_FOUND, + [ + 'photo_id' => $photo->id, + 'event_id' => $event->id, + ] + ); + } + /** * Store a newly uploaded photo. */ @@ -856,4 +968,57 @@ class PhotoController extends Controller 'status' => 'pending', ], 201); } + + 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 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))); + } } diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index 8c2ec2c..534e491 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -54,15 +54,11 @@ class PhotoResource extends JsonResource return null; } - $route = $variant === 'thumbnail' - ? 'api.v1.gallery.photos.asset' - : 'api.v1.gallery.photos.asset'; - return \URL::temporarySignedRoute( - $route, + 'api.v1.tenant.events.photos.asset', now()->addMinutes(30), [ - 'token' => $this->event->slug, // tenant/admin views are trusted; token not used server-side for signed validation + 'event' => $this->event->slug, 'photo' => $this->id, 'variant' => $variant === 'thumbnail' ? 'thumbnail' : 'full', ] diff --git a/routes/api.php b/routes/api.php index 1b7a432..9bfd83a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -127,6 +127,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store']) ->name('photobooth.sparkbooth.upload'); + + Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset']) + ->whereNumber('photo') + ->where('variant', 'thumbnail|full') + ->middleware('signed') + ->name('tenant.events.photos.asset'); }); Route::middleware(['auth:sanctum', 'tenant.collaborator', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () {