From ddbfa38db151ae1ac1245f67c5f52a7e1a77ec6a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 7 Feb 2026 14:31:48 +0100 Subject: [PATCH] Improve guest photo downloads with preview/original variants --- .../Controllers/Api/EventPublicController.php | 101 +++++++++++- .../Requests/Tenant/EventStoreRequest.php | 1 + .../guest-v2/__tests__/GalleryScreen.test.tsx | 38 ++++- .../js/guest-v2/screens/GalleryScreen.tsx | 57 ++++++- .../guest-v2/screens/PhotoLightboxScreen.tsx | 20 ++- .../Api/Event/EventGalleryDownloadTest.php | 149 ++++++++++++++++++ .../Api/Event/EventPhotosLocaleTest.php | 4 + 7 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Api/Event/EventGalleryDownloadTest.php diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 885e26f8..f1928ab2 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -53,6 +53,10 @@ class EventPublicController extends BaseController private const BRANDING_SIGNED_TTL_SECONDS = 3600; + private const PREVIEW_MAX_EDGE = 1920; + + private const PREVIEW_QUALITY = 86; + private ?GuestPolicySetting $guestPolicy = null; public function __construct( @@ -1451,17 +1455,34 @@ class EventPublicController extends BaseController } private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string + { + return $this->makeSignedGalleryDownloadUrlForId($token, (int) $photo->id); + } + + private function makeSignedGalleryDownloadUrlForId(string $token, int $photoId): string { return URL::temporarySignedRoute( 'api.v1.gallery.photos.download', now()->addSeconds(self::SIGNED_URL_TTL_SECONDS), [ 'token' => $token, - 'photo' => $photo->id, + 'photo' => $photoId, ] ); } + private function galleryDownloadVariantPreference(Event $event): array + { + $settings = is_array($event->settings) ? $event->settings : []; + $configuredVariant = Arr::get($settings, 'guest_download_variant', 'preview'); + + if ($configuredVariant === 'original') { + return ['original']; + } + + return ['preview', 'original']; + } + private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string { return URL::temporarySignedRoute( @@ -1907,7 +1928,12 @@ class EventPublicController extends BaseController ); } - return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment'); + return $this->streamGalleryPhoto( + $event, + $record, + $this->galleryDownloadVariantPreference($event), + 'attachment' + ); } public function event(Request $request, string $token) @@ -2219,6 +2245,15 @@ class EventPublicController extends BaseController $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'; + } elseif ($variant === 'preview') { + $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'preview')->first(); + $watermarked = $preferOriginals + ? null + : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_preview')->first(); + $fallbackAsset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); + $disk = $watermarked?->disk ?? $asset?->disk ?? $fallbackAsset?->disk; + $path = $watermarked?->path ?? $asset?->path ?? $fallbackAsset?->path ?? ($record->file_path ?? null); + $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? $fallbackAsset?->mime_type ?? ($record->mime_type ?? 'image/jpeg'); } else { $watermarked = $preferOriginals ? null @@ -2909,10 +2944,15 @@ class EventPublicController extends BaseController $query->where('photos.created_at', '>', $since); } $rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) { - $r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full') + $fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full') ?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? '')); - $r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail') + $thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail') ?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? '')); + $r->file_path = $fullUrl; + $r->thumbnail_path = $thumbnailUrl; + $r->full_url = $fullUrl; + $r->thumbnail_url = $thumbnailUrl; + $r->download_url = $this->makeSignedGalleryDownloadUrlForId($token, (int) $r->id); // Localize task title if present if ($r->task_title) { @@ -3346,10 +3386,19 @@ class EventPublicController extends BaseController $thumbUrl = $thumbPath ? $this->resolveDiskUrl($disk, $thumbPath) : $this->resolveDiskUrl($disk, $path); + $previewRel = "events/{$eventId}/photos/previews/{$baseName}_preview.jpg"; + $previewPath = ImageHelper::makeThumbnailOnDisk( + $disk, + $path, + $previewRel, + self::PREVIEW_MAX_EDGE, + self::PREVIEW_QUALITY + ); // Create watermarked copies (non-destructive). $watermarkedPath = $path; $watermarkedThumb = $thumbPath ?: $path; + $watermarkedPreview = $previewPath ?: $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) { @@ -3362,6 +3411,17 @@ class EventPublicController extends BaseController } else { $watermarkedThumb = $watermarkedPath; } + + if ($previewPath) { + $watermarkedPreview = ImageHelper::copyWithWatermark( + $disk, + $previewPath, + "events/{$eventId}/photos/watermarked/{$baseName}_preview.jpg", + $watermarkConfig + ) ?? $previewPath; + } else { + $watermarkedPreview = $watermarkedPath; + } } $url = $this->resolveDiskUrl($disk, $watermarkedPath); @@ -3475,6 +3535,23 @@ class EventPublicController extends BaseController ], ]); } + + if ($previewPath) { + $this->eventStorageManager->recordAsset($eventModel, $disk, $previewPath, [ + 'variant' => 'preview', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + 'size_bytes' => Storage::disk($disk)->exists($previewPath) + ? Storage::disk($disk)->size($previewPath) + : null, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + if ($watermarkedThumb !== $thumbPath) { $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [ 'variant' => 'watermarked_thumbnail', @@ -3491,6 +3568,22 @@ class EventPublicController extends BaseController ]); } + if ($watermarkedPreview !== $previewPath) { + $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPreview, [ + 'variant' => 'watermarked_preview', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + 'size_bytes' => Storage::disk($disk)->exists($watermarkedPreview) + ? Storage::disk($disk)->size($watermarkedPreview) + : null, + 'meta' => [ + 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, + ], + ]); + } + DB::table('photos') ->where('id', $photoId) ->update(['media_asset_id' => $asset->id]); diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index 2196517f..9a13989f 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -55,6 +55,7 @@ class EventStoreRequest extends FormRequest 'settings.branding.*' => ['nullable'], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], 'settings.guest_downloads_enabled' => ['nullable', 'boolean'], + 'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])], 'settings.guest_sharing_enabled' => ['nullable', 'boolean'], 'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])], 'settings.live_show' => ['nullable', 'array'], diff --git a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx index f3b2ca3f..0afdd50d 100644 --- a/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx +++ b/resources/js/guest-v2/__tests__/GalleryScreen.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const setSearchParamsMock = vi.fn(); const pushGuestToastMock = vi.fn(); @@ -199,4 +199,40 @@ describe('GalleryScreen', () => { expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument() ); }); + + it('uses download_url when downloading from lightbox', async () => { + fetchGalleryMock.mockResolvedValue({ + data: [{ + id: 123, + thumbnail_url: '/storage/demo-thumb.jpg', + download_url: '/api/v1/gallery/demo/photos/123/download?signature=abc', + likes_count: 2, + }], + }); + fetchPhotoMock.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 })); + + const originalCreateElement = document.createElement.bind(document); + const clickSpy = vi.fn(); + let createdLink: HTMLAnchorElement | null = null; + + const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => { + const element = originalCreateElement(tagName) as HTMLElement; + if (tagName.toLowerCase() === 'a') { + createdLink = element as HTMLAnchorElement; + (createdLink as any).click = clickSpy; + } + return element; + }); + + render(); + + await waitFor(() => expect(fetchGalleryMock).toHaveBeenCalled()); + const downloadButton = await screen.findByRole('button', { name: /download/i }); + fireEvent.click(downloadButton); + + expect(clickSpy).toHaveBeenCalled(); + expect(createdLink?.getAttribute('href')).toBe('/api/v1/gallery/demo/photos/123/download?signature=abc'); + + createElementSpy.mockRestore(); + }); }); diff --git a/resources/js/guest-v2/screens/GalleryScreen.tsx b/resources/js/guest-v2/screens/GalleryScreen.tsx index 223bc94b..12cc0767 100644 --- a/resources/js/guest-v2/screens/GalleryScreen.tsx +++ b/resources/js/guest-v2/screens/GalleryScreen.tsx @@ -25,6 +25,8 @@ type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth'; type GalleryTile = { id: number; imageUrl: string; + fullUrl?: string | null; + downloadUrl?: string | null; likes: number; createdAt?: string | null; ingestSource?: string | null; @@ -42,6 +44,8 @@ type GalleryTile = { type LightboxPhoto = { id: number; imageUrl: string; + fullUrl?: string | null; + downloadUrl?: string | null; likes: number; isMine?: boolean; taskId?: number | null; @@ -63,6 +67,9 @@ function normalizeImageUrl(src?: string | null) { } let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/'); + if (cleanPath.startsWith('api/')) { + return `/${cleanPath}`; + } if (!cleanPath.startsWith('storage/')) { cleanPath = `storage/${cleanPath}`; } @@ -175,14 +182,23 @@ export default function GalleryScreen() { const record = photo as Record; const id = Number(record.id ?? 0); const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0; + const fullUrl = normalizeImageUrl( + (record.full_url as string | null | undefined) + ?? (record.file_path as string | null | undefined) + ?? (record.url as string | null | undefined) + ?? (record.image_url as string | null | undefined) + ); const imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) - ?? (record.file_path as string | null | undefined) - ?? (record.full_url as string | null | undefined) + ?? fullUrl ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); + const downloadUrl = normalizeImageUrl( + (record.download_url as string | null | undefined) + ?? fullUrl + ); const rawTaskId = Number(record.task_id ?? record.taskId ?? 0); const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null; const taskLabel = @@ -226,6 +242,8 @@ export default function GalleryScreen() { return { id, imageUrl, + fullUrl: fullUrl || null, + downloadUrl: downloadUrl || null, likes: likesCount, createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, @@ -379,14 +397,23 @@ export default function GalleryScreen() { const record = photo as Record; const id = Number(record.id ?? 0); const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 0; + const fullUrl = normalizeImageUrl( + (record.full_url as string | null | undefined) + ?? (record.file_path as string | null | undefined) + ?? (record.url as string | null | undefined) + ?? (record.image_url as string | null | undefined) + ); const imageUrl = normalizeImageUrl( (record.thumbnail_url as string | null | undefined) ?? (record.thumbnail_path as string | null | undefined) - ?? (record.file_path as string | null | undefined) - ?? (record.full_url as string | null | undefined) + ?? fullUrl ?? (record.url as string | null | undefined) ?? (record.image_url as string | null | undefined) ); + const downloadUrl = normalizeImageUrl( + (record.download_url as string | null | undefined) + ?? fullUrl + ); if (!id || !imageUrl || existing.has(id)) { return null; } @@ -395,6 +422,8 @@ export default function GalleryScreen() { return { id, imageUrl, + fullUrl: fullUrl || null, + downloadUrl: downloadUrl || null, likes: likesCount, createdAt: typeof record.created_at === 'string' ? record.created_at : null, ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null, @@ -447,6 +476,8 @@ export default function GalleryScreen() { ? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, + fullUrl: lightboxSelected.fullUrl ?? null, + downloadUrl: lightboxSelected.downloadUrl ?? null, likes: lightboxSelected.likes, isMine: lightboxSelected.isMine, taskId: lightboxSelected.taskId ?? null, @@ -764,9 +795,10 @@ export default function GalleryScreen() { ); const downloadPhoto = React.useCallback((photo?: LightboxPhoto | null) => { - if (!photo?.imageUrl) return; + const url = photo?.downloadUrl ?? photo?.fullUrl ?? photo?.imageUrl ?? null; + if (!url) return; const link = document.createElement('a'); - link.href = photo.imageUrl; + link.href = url; link.download = `photo-${photo.id}.jpg`; link.rel = 'noreferrer'; document.body.appendChild(link); @@ -1498,14 +1530,23 @@ export default function GalleryScreen() { function mapFullPhoto(photo: Record): LightboxPhoto | null { const id = Number(photo.id ?? 0); if (!id) return null; - const imageUrl = normalizeImageUrl( + const fullUrl = normalizeImageUrl( (photo.full_url as string | null | undefined) ?? (photo.file_path as string | null | undefined) + ?? (photo.url as string | null | undefined) + ?? (photo.image_url as string | null | undefined) + ); + const imageUrl = normalizeImageUrl( + fullUrl ?? (photo.thumbnail_url as string | null | undefined) ?? (photo.thumbnail_path as string | null | undefined) ?? (photo.url as string | null | undefined) ?? (photo.image_url as string | null | undefined) ); + const downloadUrl = normalizeImageUrl( + (photo.download_url as string | null | undefined) + ?? fullUrl + ); if (!imageUrl) return null; const taskLabel = typeof photo.task_title === 'string' @@ -1550,6 +1591,8 @@ function mapFullPhoto(photo: Record): LightboxPhoto | null { return { id, imageUrl, + fullUrl: fullUrl || null, + downloadUrl: downloadUrl || null, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, isMine, taskId, diff --git a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx index fa08b697..7d824ec9 100644 --- a/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx +++ b/resources/js/guest-v2/screens/PhotoLightboxScreen.tsx @@ -22,6 +22,8 @@ import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags'; type LightboxPhoto = { id: number; imageUrl: string; + fullUrl?: string | null; + downloadUrl?: string | null; likes: number; createdAt?: string | null; ingestSource?: string | null; @@ -37,6 +39,9 @@ function normalizeImageUrl(src?: string | null) { } let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/'); + if (cleanPath.startsWith('api/')) { + return `/${cleanPath}`; + } if (!cleanPath.startsWith('storage/')) { cleanPath = `storage/${cleanPath}`; } @@ -47,18 +52,29 @@ function normalizeImageUrl(src?: string | null) { function mapPhoto(photo: Record): LightboxPhoto | null { const id = Number(photo.id ?? 0); if (!id) return null; - const imageUrl = normalizeImageUrl( + const fullUrl = normalizeImageUrl( (photo.full_url as string | null | undefined) ?? (photo.file_path as string | null | undefined) + ?? (photo.url as string | null | undefined) + ?? (photo.image_url as string | null | undefined) + ); + const imageUrl = normalizeImageUrl( + fullUrl ?? (photo.thumbnail_url as string | null | undefined) ?? (photo.thumbnail_path as string | null | undefined) ?? (photo.url as string | null | undefined) ?? (photo.image_url as string | null | undefined) ); + const downloadUrl = normalizeImageUrl( + (photo.download_url as string | null | undefined) + ?? fullUrl + ); if (!imageUrl) return null; return { id, imageUrl, + fullUrl: fullUrl || null, + downloadUrl: downloadUrl || null, likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0, createdAt: typeof photo.created_at === 'string' ? photo.created_at : null, ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null, @@ -617,7 +633,7 @@ export default function PhotoLightboxScreen() { >