Improve guest photo downloads with preview/original variants
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
149
tests/Feature/Api/Event/EventGalleryDownloadTest.php
Normal file
149
tests/Feature/Api/Event/EventGalleryDownloadTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user