attributes->get('tenant_id'); $event = Event::with([ 'tenant', 'eventPackage.package', 'eventPackages.package', 'storageAssignments.storageTarget', ])->where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); $tenant = $event->tenant; $eventPackage = $tenant ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; $limitSummary = $eventPackage ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) : null; $query = Photo::where('event_id', $event->id) ->with('event')->withCount('likes'); // Filters if ($request->has('status')) { $query->where('status', $request->status); } if ($request->boolean('featured')) { $query->where('is_featured', true); } if ($request->has('user_id')) { $query->where('uploader_id', $request->user_id); } if ($request->filled('ingest_source')) { $query->where('ingest_source', $request->string('ingest_source')); } $visibility = strtolower((string) $request->input('visibility', '')); if ($visibility === 'visible') { $query->where('status', '!=', 'hidden'); } elseif ($visibility === 'hidden') { $query->where('status', 'hidden'); } if ($request->filled('search')) { $term = strtolower(trim((string) $request->get('search'))); $query->where(function ($inner) use ($term) { $inner->whereRaw('LOWER(original_name) LIKE ?', ['%'.$term.'%']) ->orWhereRaw('LOWER(filename) LIKE ?', ['%'.$term.'%']); }); } $direction = strtolower($request->get('sort', 'desc')) === 'asc' ? 'asc' : 'desc'; $query->orderBy('created_at', $direction); $perPage = max(1, min((int) $request->get('per_page', 40), 100)); $photos = $query->paginate($perPage); return PhotoResource::collection($photos)->additional([ 'limits' => $limitSummary, ]); } public function visibility(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); 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] ); } $validated = $request->validate([ 'visible' => ['required', 'boolean'], ]); $photo->status = $validated['visible'] ? 'approved' : 'hidden'; $photo->save(); $photo->load('event')->loadCount('likes'); return response()->json([ 'message' => 'Photo visibility updated', 'data' => new PhotoResource($photo), ]); } 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. */ public function store(PhotoStoreRequest $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $event->loadMissing(['tenant', 'eventPackage.package', 'eventPackages.package']); $tenant = $event->tenant; $eventPackage = $tenant ? $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload($tenant, $event->id, $event) : null; $previousUsedPhotos = $eventPackage?->used_photos ?? 0; $validated = $request->validated(); $file = $request->file('photo'); if (! $file) { throw ValidationException::withMessages([ 'photo' => 'No photo file uploaded.', ]); } // Validate file type and size $allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (! in_array($file->getMimeType(), $allowedTypes)) { throw ValidationException::withMessages([ 'photo' => 'Only JPEG, PNG, and WebP images are allowed.', ]); } if ($file->getSize() > 10 * 1024 * 1024) { // 10MB throw ValidationException::withMessages([ 'photo' => 'Photo size must be less than 10MB.', ]); } if ($tenant) { $violation = $this->packageLimitEvaluator->assessPhotoUpload( $tenant, $event->id, $event, $file->getSize() ); if ($violation !== null) { return ApiError::response( $violation['code'], $violation['title'], $violation['message'], $violation['status'], $violation['meta'] ); } } // Determine storage target $disk = $this->eventStorageManager->getHotDiskForEvent($event); // Generate unique filename $extension = $file->getClientOriginalExtension(); $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/photos/{$filename}"; // Store original file UploadStream::putUploadedFile($disk, $path, $file); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } // Apply watermark policy (in-place) to original and thumbnail where applicable. $watermarkConfig = WatermarkConfigResolver::resolve($event); if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset'])) { ImageHelper::applyWatermarkOnDisk($disk, $path, $watermarkConfig); if ($thumbnailRelative) { ImageHelper::applyWatermarkOnDisk($disk, $thumbnailPath, $watermarkConfig); } } $watermarkConfig = WatermarkConfigResolver::resolve($event); $watermarkedPath = $path; $watermarkedThumb = $thumbnailPath; if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { $watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventSlug}/watermarked/{$filename}", $watermarkConfig) ?? $path; if ($thumbnailRelative) { $watermarkedThumb = ImageHelper::copyWithWatermark( $disk, $thumbnailPath, "events/{$eventSlug}/watermarked/thumbnails/{$filename}", $watermarkConfig ) ?? $thumbnailPath; } } $photoAttributes = [ 'event_id' => $event->id, 'guest_name' => Photo::SOURCE_TENANT_ADMIN, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'file_path' => $watermarkedPath, 'thumbnail_path' => $watermarkedThumb, 'width' => null, // Filled below 'height' => null, 'status' => 'approved', 'uploader_id' => null, 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'ingest_source' => Photo::SOURCE_TENANT_ADMIN, ]; if (Photo::supportsFilenameColumn()) { $photoAttributes['filename'] = $filename; } if (Photo::hasColumn('path')) { $photoAttributes['path'] = $path; } $photo = Photo::create($photoAttributes); // Record primary asset metadata $storedPath = Storage::disk($disk)->path($path); $checksum = file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()); $storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize(); $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getMimeType(), 'size_bytes' => $storedSize, 'checksum' => $checksum, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); $watermarkedAsset = null; if ($watermarkedPath !== $path) { $watermarkedAsset = $this->eventStorageManager->recordAsset($event, $disk, $watermarkedPath, [ 'variant' => 'watermarked', 'mime_type' => $file->getMimeType(), 'size_bytes' => Storage::disk($disk)->exists($watermarkedPath) ? Storage::disk($disk)->size($watermarkedPath) : null, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } if ($thumbnailRelative) { $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) ? Storage::disk($disk)->size($thumbnailRelative) : null, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } if ($watermarkedThumb !== $thumbnailPath) { $this->eventStorageManager->recordAsset($event, $disk, $watermarkedThumb, [ 'variant' => 'watermarked_thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb) ? Storage::disk($disk)->size($watermarkedThumb) : null, 'meta' => [ 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, ], ]); } $photo->update(['media_asset_id' => $asset->id]); // Get image dimensions [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); ProcessPhotoSecurityScan::dispatch($photo->id); if ($eventPackage) { $eventPackage->increment('used_photos'); $eventPackage->refresh(); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsedPhotos, 1); } $limitSummary = $eventPackage ? $this->packageLimitEvaluator->summarizeEventPackage($eventPackage) : null; $photo->load('event')->loadCount('likes'); return response()->json([ 'message' => 'Photo uploaded successfully. Awaiting moderation.', 'data' => new PhotoResource($photo), 'moderation_notice' => 'Your photo has been uploaded and will be reviewed shortly.', 'limits' => $limitSummary, ], 201); } /** * Display the specified photo. */ public function show(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $photo->load('event')->loadCount('likes'); $photo->increment('view_count'); return response()->json([ 'data' => new PhotoResource($photo), ]); } /** * Update the specified photo (moderation or metadata). */ public function update(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $validated = $request->validate([ 'status' => ['sometimes', 'in:pending,approved,rejected'], 'moderation_notes' => ['sometimes', 'required_if:status,rejected', 'string', 'max:1000'], 'caption' => ['sometimes', 'string', 'max:500'], 'alt_text' => ['sometimes', 'string', 'max:255'], ]); // Only tenant admins can moderate if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) { return ApiError::response( 'insufficient_scope', 'Insufficient Scopes', 'You are not allowed to moderate photos for this event.', Response::HTTP_FORBIDDEN, ['required_scope' => 'tenant-admin'] ); } $photo->update($validated); if ($validated['status'] ?? null === 'approved') { $photo->load('event')->loadCount('likes'); // Trigger event for new photo notification // event(new \App\Events\PhotoApproved($photo)); // Implement later } return response()->json([ 'message' => 'Photo updated successfully', 'data' => new PhotoResource($photo), ]); } /** * Remove the specified photo from storage. */ public function destroy(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Foto nicht gefunden', 'Das Foto gehört nicht zu diesem Event.', 404, ['photo_id' => $photo->id] ); } $assets = EventMediaAsset::where('photo_id', $photo->id)->get(); foreach ($assets as $asset) { if (! is_string($asset->path) || $asset->path === '') { continue; } try { Storage::disk($asset->disk)->delete($asset->path); } catch (\Throwable $e) { Log::warning('Failed to delete asset from storage', [ 'asset_id' => $asset->id, 'disk' => $asset->disk, 'path' => $asset->path, 'error' => $e->getMessage(), ]); } } // Ensure legacy paths are removed if assets missing if ($assets->isEmpty()) { $fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($event); $paths = array_values(array_filter([ is_string($photo->path) ? $photo->path : null, is_string($photo->thumbnail_path) ? $photo->thumbnail_path : null, ])); if (! empty($paths)) { Storage::disk($fallbackDisk)->delete($paths); } } $eventPackage = $event->eventPackage; $usageTracker = app(\App\Services\Packages\PackageUsageTracker::class); // Delete record and likes DB::transaction(function () use ($photo, $assets) { $photo->likes()->delete(); if ($assets->isNotEmpty()) { EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete(); } $photo->delete(); }); if ($eventPackage && $eventPackage->package) { $previousUsed = (int) $eventPackage->used_photos; if ($previousUsed > 0) { $eventPackage->decrement('used_photos'); $eventPackage->refresh(); $usageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1); } } return response()->json([ 'message' => 'Photo deleted successfully', ]); } /** * Bulk approve photos (admin only) */ public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Photo not found', 'The specified photo could not be located for this event.', Response::HTTP_NOT_FOUND, ['photo_id' => $photo->id, 'event_id' => $event->id] ); } $photo->update(['is_featured' => true]); $photo->refresh()->load('event')->loadCount('likes'); return response()->json(['message' => 'Photo marked as featured', 'data' => new PhotoResource($photo)]); } public function unfeature(Request $request, string $eventSlug, Photo $photo): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); if ($photo->event_id !== $event->id) { return ApiError::response( 'photo_not_found', 'Photo not found', 'The specified photo could not be located for this event.', Response::HTTP_NOT_FOUND, ['photo_id' => $photo->id, 'event_id' => $event->id] ); } $photo->update(['is_featured' => false]); $photo->refresh()->load('event')->loadCount('likes'); return response()->json(['message' => 'Photo removed from featured', 'data' => new PhotoResource($photo)]); } public function bulkApprove(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'photo_ids' => 'required|array', 'photo_ids.*' => 'exists:photos,id', 'moderation_notes' => 'nullable|string|max:1000', ]); $photoIds = $request->photo_ids; $approvedCount = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->where('status', 'pending') ->update([ 'status' => 'approved', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), 'moderated_by' => null, ]); // Load approved photos for response $photos = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->with('event')->withCount('likes') ->get(); // Trigger events foreach ($photos as $photo) { // event(new \App\Events\PhotoApproved($photo)); // Implement later } return response()->json([ 'message' => "{$approvedCount} photos approved successfully", 'approved_count' => $approvedCount, 'data' => PhotoResource::collection($photos), ]); } /** * Bulk reject photos (admin only) */ public function bulkReject(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'photo_ids' => 'required|array', 'photo_ids.*' => 'exists:photos,id', 'moderation_notes' => 'required|string|max:1000', ]); $photoIds = $request->photo_ids; $rejectedCount = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->where('status', 'pending') ->update([ 'status' => 'rejected', 'moderation_notes' => $request->moderation_notes, 'moderated_at' => now(), 'moderated_by' => null, ]); // Optionally delete rejected photos from storage $rejectedPhotos = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) ->get(); foreach ($rejectedPhotos as $photo) { Storage::disk('public')->delete([ $photo->path, $photo->thumbnail_path, ]); } return response()->json([ 'message' => "{$rejectedCount} photos rejected and deleted", 'rejected_count' => $rejectedCount, ]); } /** * Get photos for moderation (admin only) */ public function forModeration(Request $request, string $eventSlug): AnonymousResourceCollection { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $photos = Photo::where('event_id', $event->id) ->where('status', 'pending') ->with('event')->withCount('likes') ->orderBy('created_at', 'desc') ->paginate($request->get('per_page', 20)); return PhotoResource::collection($photos); } /** * Get upload statistics for event */ public function stats(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $totalPhotos = Photo::where('event_id', $event->id)->count(); $pendingPhotos = Photo::where('event_id', $event->id)->where('status', 'pending')->count(); $approvedPhotos = Photo::where('event_id', $event->id)->where('status', 'approved')->count(); $totalLikes = DB::table('photo_likes')->whereIn('photo_id', Photo::where('event_id', $event->id)->pluck('id') )->count(); $totalStorage = Photo::where('event_id', $event->id)->sum('size'); $uniqueUploaders = Photo::where('event_id', $event->id) ->select('uploader_id') ->distinct() ->count('uploader_id'); $recentUploads = Photo::where('event_id', $event->id) ->where('created_at', '>=', now()->subDays(7)) ->count(); return response()->json([ 'event_id' => $event->id, 'total_photos' => $totalPhotos, 'pending_photos' => $pendingPhotos, 'approved_photos' => $approvedPhotos, 'total_likes' => $totalLikes, 'total_storage_bytes' => $totalStorage, 'total_storage_mb' => round($totalStorage / (1024 * 1024), 2), 'unique_uploaders' => $uniqueUploaders, 'recent_uploads_7d' => $recentUploads, 'storage_quota_remaining' => $event->tenant->storage_quota - $totalStorage, 'quota_percentage_used' => min(100, round(($totalStorage / $event->tenant->storage_quota) * 100, 1)), ]); } private function tokenHasScope(Request $request, string $scope): bool { $accessToken = $request->user()?->currentAccessToken(); if ($accessToken && $accessToken->can($scope)) { return true; } $scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []); if (! is_array($scopes)) { $scopes = array_values(array_filter(explode(' ', (string) $scopes))); } return in_array($scope, $scopes, true); } /** * Generate presigned S3 URL for direct upload (alternative to local storage) */ public function presignedUpload(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'filename' => 'required|string|max:255', 'content_type' => 'required|string|in:image/jpeg,image/png,image/webp', ]); // Generate unique filename $extension = pathinfo($request->filename, PATHINFO_EXTENSION); $filename = Str::uuid().'.'.$extension; $path = "events/{$eventSlug}/pending/{$filename}"; // For local storage, return direct upload endpoint // For S3, use Storage::disk('s3')->temporaryUrl() or presigned URL $uploadUrl = url("/api/v1/tenant/events/{$eventSlug}/upload-direct"); $fields = [ 'event_id' => $event->id, 'filename' => $filename, 'original_name' => $request->filename, ]; return response()->json([ 'upload_url' => $uploadUrl, 'fields' => $fields, 'path' => $path, 'max_size' => 10 * 1024 * 1024, // 10MB 'allowed_types' => ['image/jpeg', 'image/png', 'image/webp'], ]); } /** * Direct upload endpoint for presigned uploads */ public function uploadDirect(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); $request->validate([ 'event_id' => 'required|exists:events,id', 'filename' => 'required|string', 'original_name' => 'required|string', 'photo' => 'required|image|mimes:jpeg,png,webp|max:10240', // 10MB ]); if ($request->event_id !== $event->id) { return ApiError::response( 'event_mismatch', 'Invalid Event', 'The provided event does not match the authenticated tenant event.', Response::HTTP_BAD_REQUEST, ['payload_event_id' => $request->event_id, 'expected_event_id' => $event->id] ); } $event->load('storageAssignments.storageTarget'); $disk = $this->eventStorageManager->getHotDiskForEvent($event); $file = $request->file('photo'); $filename = $request->filename; $path = "events/{$eventSlug}/photos/{$filename}"; // Store file UploadStream::putUploadedFile($disk, $path, $file); // Generate thumbnail $thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}"; $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400); if ($thumbnailRelative) { $thumbnailPath = $thumbnailRelative; } $photoAttributes = [ 'event_id' => $event->id, 'guest_name' => Photo::SOURCE_TENANT_ADMIN, 'original_name' => $request->original_name, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'file_path' => $path, 'thumbnail_path' => $thumbnailPath, 'status' => 'pending', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'ingest_source' => Photo::SOURCE_TENANT_ADMIN, ]; if (Photo::supportsFilenameColumn()) { $photoAttributes['filename'] = $filename; } if (Photo::hasColumn('path')) { $photoAttributes['path'] = $path; } $photo = Photo::create($photoAttributes); [$width, $height] = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); $checksum = hash_file('sha256', $file->getRealPath()); $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getMimeType(), 'size_bytes' => $file->getSize(), 'checksum' => $checksum, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); if ($thumbnailRelative) { $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ 'variant' => 'thumbnail', 'mime_type' => 'image/jpeg', 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, 'size_bytes' => Storage::disk($disk)->exists($thumbnailRelative) ? Storage::disk($disk)->size($thumbnailRelative) : null, 'meta' => [ 'source_variant_id' => $asset->id, ], ]); } $photo->update(['media_asset_id' => $asset->id]); return response()->json([ 'message' => 'Upload successful. Awaiting moderation.', 'photo_id' => $photo->id, '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))); } }