Improve guest photo downloads with preview/original variants
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 14:31:48 +01:00
parent 3ba4d11d92
commit ddbfa38db1
7 changed files with 356 additions and 14 deletions

View File

@@ -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]);

View File

@@ -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'],

View File

@@ -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(<GalleryScreen />);
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();
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>): 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<string, unknown>): LightboxPhoto | null {
return {
id,
imageUrl,
fullUrl: fullUrl || null,
downloadUrl: downloadUrl || null,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
isMine,
taskId,

View File

@@ -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<string, unknown>): 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() {
>
<Button
unstyled
onPress={() => downloadPhoto(selected?.imageUrl ?? null, selected?.id ?? null)}
onPress={() => downloadPhoto(selected?.downloadUrl ?? selected?.fullUrl ?? selected?.imageUrl ?? null, selected?.id ?? null)}
paddingHorizontal="$3"
paddingVertical="$2"
>

View File

@@ -0,0 +1,149 @@
<?php
namespace Tests\Feature\Api\Event;
use App\Models\Event;
use App\Models\EventMediaAsset;
use App\Models\MediaStorageTarget;
use App\Models\Photo;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class EventGalleryDownloadTest extends TestCase
{
use RefreshDatabase;
public function test_gallery_download_uses_preview_variant_when_configured(): void
{
Storage::fake('public');
$event = Event::factory()->create([
'status' => 'published',
'settings' => [
'guest_download_variant' => 'preview',
],
]);
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'download-preview'])->plain_token;
$storageTarget = $this->createPublicStorageTarget();
$originalPath = "events/{$event->id}/photos/original.jpg";
$previewPath = "events/{$event->id}/photos/previews/preview.jpg";
$thumbnailPath = "events/{$event->id}/photos/thumbs/thumb.jpg";
Storage::disk('public')->put($originalPath, 'original-content');
Storage::disk('public')->put($previewPath, 'preview-content');
Storage::disk('public')->put($thumbnailPath, 'thumb-content');
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
'file_path' => $originalPath,
'thumbnail_path' => $thumbnailPath,
]);
$originalAsset = EventMediaAsset::create([
'event_id' => $event->id,
'media_storage_target_id' => $storageTarget->id,
'photo_id' => $photo->id,
'variant' => 'original',
'disk' => 'public',
'path' => $originalPath,
'status' => 'hot',
'processed_at' => now(),
'mime_type' => 'image/jpeg',
]);
EventMediaAsset::create([
'event_id' => $event->id,
'media_storage_target_id' => $storageTarget->id,
'photo_id' => $photo->id,
'variant' => 'preview',
'disk' => 'public',
'path' => $previewPath,
'status' => 'hot',
'processed_at' => now(),
'mime_type' => 'image/jpeg',
]);
$photo->update(['media_asset_id' => $originalAsset->id]);
$photosResponse = $this->getJson("/api/v1/gallery/{$token}/photos");
$downloadUrl = (string) $photosResponse->json('data.0.download_url');
$photosResponse->assertOk();
$this->assertNotEmpty($downloadUrl);
$downloadResponse = $this->get($downloadUrl);
$downloadResponse->assertOk();
$this->assertSame('preview-content', $downloadResponse->streamedContent());
$this->assertStringContainsString(
'attachment;',
(string) $downloadResponse->headers->get('Content-Disposition')
);
}
public function test_gallery_download_falls_back_to_original_when_preview_is_missing(): void
{
Storage::fake('public');
$event = Event::factory()->create([
'status' => 'published',
'settings' => [
'guest_download_variant' => 'preview',
],
]);
$token = app(EventJoinTokenService::class)->createToken($event, ['label' => 'download-fallback'])->plain_token;
$storageTarget = $this->createPublicStorageTarget();
$originalPath = "events/{$event->id}/photos/original.jpg";
$thumbnailPath = "events/{$event->id}/photos/thumbs/thumb.jpg";
Storage::disk('public')->put($originalPath, 'original-content');
Storage::disk('public')->put($thumbnailPath, 'thumb-content');
$photo = Photo::factory()->for($event)->create([
'tenant_id' => $event->tenant_id,
'status' => 'approved',
'file_path' => $originalPath,
'thumbnail_path' => $thumbnailPath,
]);
$originalAsset = EventMediaAsset::create([
'event_id' => $event->id,
'media_storage_target_id' => $storageTarget->id,
'photo_id' => $photo->id,
'variant' => 'original',
'disk' => 'public',
'path' => $originalPath,
'status' => 'hot',
'processed_at' => now(),
'mime_type' => 'image/jpeg',
]);
$photo->update(['media_asset_id' => $originalAsset->id]);
$photosResponse = $this->getJson("/api/v1/gallery/{$token}/photos");
$downloadUrl = (string) $photosResponse->json('data.0.download_url');
$photosResponse->assertOk();
$this->assertNotEmpty($downloadUrl);
$downloadResponse = $this->get($downloadUrl);
$downloadResponse->assertOk();
$this->assertSame('original-content', $downloadResponse->streamedContent());
}
private function createPublicStorageTarget(): MediaStorageTarget
{
return MediaStorageTarget::create([
'key' => 'public',
'name' => 'Public',
'driver' => 'local',
'is_hot' => true,
'is_default' => true,
'is_active' => true,
]);
}
}

View File

@@ -59,6 +59,10 @@ class EventPhotosLocaleTest extends TestCase
$responseEn->assertJsonPath('data.0.emotion.icon', '🙂');
$responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
$responseEn->assertJsonPath('data.0.is_mine', true);
$this->assertStringContainsString('/api/v1/gallery/'.$token.'/photos/', (string) $responseEn->json('data.0.full_url'));
$this->assertStringContainsString('/api/v1/gallery/'.$token.'/photos/', (string) $responseEn->json('data.0.thumbnail_url'));
$this->assertStringContainsString('/api/v1/gallery/'.$token.'/photos/', (string) $responseEn->json('data.0.download_url'));
$this->assertStringContainsString('/download', (string) $responseEn->json('data.0.download_url'));
$etag = $responseEn->headers->get('ETag');
$this->assertNotEmpty($etag);