Files
fotospiel-app/app/Http/Controllers/Api/Tenant/PhotoController.php
Codex Agent c73a3163c0 behoben: ohne aufgabe kann die kamera nicht gestartet werden (offensichtlich kein fehler mit browserzugriff auf kamera!)
platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden?
	Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!)
	hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt.
	geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen"
	der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren).
Aufgabenseite verbessert (Zwischenstand)
2025-12-04 11:58:07 +01:00

860 lines
31 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\PhotoStoreRequest;
use App\Http\Resources\Tenant\PhotoResource;
use App\Jobs\ProcessPhotoSecurityScan;
use App\Models\Event;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use App\Services\Packages\PackageLimitEvaluator;
use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
class PhotoController extends Controller
{
public function __construct(
private readonly EventStorageManager $eventStorageManager,
private readonly PackageLimitEvaluator $packageLimitEvaluator,
private readonly PackageUsageTracker $packageUsageTracker,
) {}
/**
* Display a listing of the event's photos.
*/
public function index(Request $request, string $eventSlug): AnonymousResourceCollection
{
$tenantId = $request->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),
]);
}
/**
* 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;
if ($tenant) {
$violation = $this->packageLimitEvaluator->assessPhotoUpload($tenant, $event->id, $event);
if ($violation !== null) {
return ApiError::response(
$violation['code'],
$violation['title'],
$violation['message'],
$violation['status'],
$violation['meta']
);
}
}
$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.',
]);
}
// 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
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
// 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,
'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:write')) {
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write']
);
}
$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
{
$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
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
// Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbnailPath, 400);
if ($thumbnailRelative) {
$thumbnailPath = $thumbnailRelative;
}
$photoAttributes = [
'event_id' => $event->id,
'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);
}
}