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 BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
|
private const PREVIEW_MAX_EDGE = 1920;
|
||||||
|
|
||||||
|
private const PREVIEW_QUALITY = 86;
|
||||||
|
|
||||||
private ?GuestPolicySetting $guestPolicy = null;
|
private ?GuestPolicySetting $guestPolicy = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -1451,17 +1455,34 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
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(
|
return URL::temporarySignedRoute(
|
||||||
'api.v1.gallery.photos.download',
|
'api.v1.gallery.photos.download',
|
||||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||||
[
|
[
|
||||||
'token' => $token,
|
'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
|
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||||||
{
|
{
|
||||||
return URL::temporarySignedRoute(
|
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)
|
public function event(Request $request, string $token)
|
||||||
@@ -2219,6 +2245,15 @@ class EventPublicController extends BaseController
|
|||||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||||
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
$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 {
|
} else {
|
||||||
$watermarked = $preferOriginals
|
$watermarked = $preferOriginals
|
||||||
? null
|
? null
|
||||||
@@ -2909,10 +2944,15 @@ class EventPublicController extends BaseController
|
|||||||
$query->where('photos.created_at', '>', $since);
|
$query->where('photos.created_at', '>', $since);
|
||||||
}
|
}
|
||||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
|
$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 ?? ''));
|
?? $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 ?? ''));
|
?? $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
|
// Localize task title if present
|
||||||
if ($r->task_title) {
|
if ($r->task_title) {
|
||||||
@@ -3346,10 +3386,19 @@ class EventPublicController extends BaseController
|
|||||||
$thumbUrl = $thumbPath
|
$thumbUrl = $thumbPath
|
||||||
? $this->resolveDiskUrl($disk, $thumbPath)
|
? $this->resolveDiskUrl($disk, $thumbPath)
|
||||||
: $this->resolveDiskUrl($disk, $path);
|
: $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).
|
// Create watermarked copies (non-destructive).
|
||||||
$watermarkedPath = $path;
|
$watermarkedPath = $path;
|
||||||
$watermarkedThumb = $thumbPath ?: $path;
|
$watermarkedThumb = $thumbPath ?: $path;
|
||||||
|
$watermarkedPreview = $previewPath ?: $path;
|
||||||
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
|
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;
|
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
|
||||||
if ($thumbPath) {
|
if ($thumbPath) {
|
||||||
@@ -3362,6 +3411,17 @@ class EventPublicController extends BaseController
|
|||||||
} else {
|
} else {
|
||||||
$watermarkedThumb = $watermarkedPath;
|
$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);
|
$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) {
|
if ($watermarkedThumb !== $thumbPath) {
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
||||||
'variant' => 'watermarked_thumbnail',
|
'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')
|
DB::table('photos')
|
||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
->update(['media_asset_id' => $asset->id]);
|
->update(['media_asset_id' => $asset->id]);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class EventStoreRequest extends FormRequest
|
|||||||
'settings.branding.*' => ['nullable'],
|
'settings.branding.*' => ['nullable'],
|
||||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||||
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
|
'settings.guest_downloads_enabled' => ['nullable', 'boolean'],
|
||||||
|
'settings.guest_download_variant' => ['nullable', Rule::in(['preview', 'original'])],
|
||||||
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
|
'settings.guest_sharing_enabled' => ['nullable', 'boolean'],
|
||||||
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
|
||||||
'settings.live_show' => ['nullable', 'array'],
|
'settings.live_show' => ['nullable', 'array'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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 setSearchParamsMock = vi.fn();
|
||||||
const pushGuestToastMock = vi.fn();
|
const pushGuestToastMock = vi.fn();
|
||||||
@@ -199,4 +199,40 @@ describe('GalleryScreen', () => {
|
|||||||
expect(screen.queryByLabelText('AI Magic Edit')).not.toBeInTheDocument()
|
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 = {
|
type GalleryTile = {
|
||||||
id: number;
|
id: number;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
fullUrl?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
likes: number;
|
likes: number;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
ingestSource?: string | null;
|
ingestSource?: string | null;
|
||||||
@@ -42,6 +44,8 @@ type GalleryTile = {
|
|||||||
type LightboxPhoto = {
|
type LightboxPhoto = {
|
||||||
id: number;
|
id: number;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
fullUrl?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
likes: number;
|
likes: number;
|
||||||
isMine?: boolean;
|
isMine?: boolean;
|
||||||
taskId?: number | null;
|
taskId?: number | null;
|
||||||
@@ -63,6 +67,9 @@ function normalizeImageUrl(src?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
||||||
|
if (cleanPath.startsWith('api/')) {
|
||||||
|
return `/${cleanPath}`;
|
||||||
|
}
|
||||||
if (!cleanPath.startsWith('storage/')) {
|
if (!cleanPath.startsWith('storage/')) {
|
||||||
cleanPath = `storage/${cleanPath}`;
|
cleanPath = `storage/${cleanPath}`;
|
||||||
}
|
}
|
||||||
@@ -175,14 +182,23 @@ export default function GalleryScreen() {
|
|||||||
const record = photo as Record<string, unknown>;
|
const record = photo as Record<string, unknown>;
|
||||||
const id = Number(record.id ?? 0);
|
const id = Number(record.id ?? 0);
|
||||||
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 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(
|
const imageUrl = normalizeImageUrl(
|
||||||
(record.thumbnail_url as string | null | undefined)
|
(record.thumbnail_url as string | null | undefined)
|
||||||
?? (record.thumbnail_path as string | null | undefined)
|
?? (record.thumbnail_path as string | null | undefined)
|
||||||
?? (record.file_path as string | null | undefined)
|
?? fullUrl
|
||||||
?? (record.full_url as string | null | undefined)
|
|
||||||
?? (record.url as string | null | undefined)
|
?? (record.url as string | null | undefined)
|
||||||
?? (record.image_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 rawTaskId = Number(record.task_id ?? record.taskId ?? 0);
|
||||||
const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null;
|
const taskId = Number.isFinite(rawTaskId) && rawTaskId > 0 ? rawTaskId : null;
|
||||||
const taskLabel =
|
const taskLabel =
|
||||||
@@ -226,6 +242,8 @@ export default function GalleryScreen() {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
fullUrl: fullUrl || null,
|
||||||
|
downloadUrl: downloadUrl || null,
|
||||||
likes: likesCount,
|
likes: likesCount,
|
||||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : 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 record = photo as Record<string, unknown>;
|
||||||
const id = Number(record.id ?? 0);
|
const id = Number(record.id ?? 0);
|
||||||
const likesCount = typeof record.likes_count === 'number' ? record.likes_count : 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(
|
const imageUrl = normalizeImageUrl(
|
||||||
(record.thumbnail_url as string | null | undefined)
|
(record.thumbnail_url as string | null | undefined)
|
||||||
?? (record.thumbnail_path as string | null | undefined)
|
?? (record.thumbnail_path as string | null | undefined)
|
||||||
?? (record.file_path as string | null | undefined)
|
?? fullUrl
|
||||||
?? (record.full_url as string | null | undefined)
|
|
||||||
?? (record.url as string | null | undefined)
|
?? (record.url as string | null | undefined)
|
||||||
?? (record.image_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)) {
|
if (!id || !imageUrl || existing.has(id)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -395,6 +422,8 @@ export default function GalleryScreen() {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
fullUrl: fullUrl || null,
|
||||||
|
downloadUrl: downloadUrl || null,
|
||||||
likes: likesCount,
|
likes: likesCount,
|
||||||
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
|
||||||
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
|
||||||
@@ -447,6 +476,8 @@ export default function GalleryScreen() {
|
|||||||
? {
|
? {
|
||||||
id: lightboxSelected.id,
|
id: lightboxSelected.id,
|
||||||
imageUrl: lightboxSelected.imageUrl,
|
imageUrl: lightboxSelected.imageUrl,
|
||||||
|
fullUrl: lightboxSelected.fullUrl ?? null,
|
||||||
|
downloadUrl: lightboxSelected.downloadUrl ?? null,
|
||||||
likes: lightboxSelected.likes,
|
likes: lightboxSelected.likes,
|
||||||
isMine: lightboxSelected.isMine,
|
isMine: lightboxSelected.isMine,
|
||||||
taskId: lightboxSelected.taskId ?? null,
|
taskId: lightboxSelected.taskId ?? null,
|
||||||
@@ -764,9 +795,10 @@ export default function GalleryScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const downloadPhoto = React.useCallback((photo?: LightboxPhoto | null) => {
|
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');
|
const link = document.createElement('a');
|
||||||
link.href = photo.imageUrl;
|
link.href = url;
|
||||||
link.download = `photo-${photo.id}.jpg`;
|
link.download = `photo-${photo.id}.jpg`;
|
||||||
link.rel = 'noreferrer';
|
link.rel = 'noreferrer';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
@@ -1498,14 +1530,23 @@ export default function GalleryScreen() {
|
|||||||
function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
||||||
const id = Number(photo.id ?? 0);
|
const id = Number(photo.id ?? 0);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const imageUrl = normalizeImageUrl(
|
const fullUrl = normalizeImageUrl(
|
||||||
(photo.full_url as string | null | undefined)
|
(photo.full_url as string | null | undefined)
|
||||||
?? (photo.file_path 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_url as string | null | undefined)
|
||||||
?? (photo.thumbnail_path as string | null | undefined)
|
?? (photo.thumbnail_path as string | null | undefined)
|
||||||
?? (photo.url as string | null | undefined)
|
?? (photo.url as string | null | undefined)
|
||||||
?? (photo.image_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;
|
if (!imageUrl) return null;
|
||||||
const taskLabel =
|
const taskLabel =
|
||||||
typeof photo.task_title === 'string'
|
typeof photo.task_title === 'string'
|
||||||
@@ -1550,6 +1591,8 @@ function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
fullUrl: fullUrl || null,
|
||||||
|
downloadUrl: downloadUrl || null,
|
||||||
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
||||||
isMine,
|
isMine,
|
||||||
taskId,
|
taskId,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { GUEST_AI_MAGIC_EDITS_ENABLED } from '../lib/featureFlags';
|
|||||||
type LightboxPhoto = {
|
type LightboxPhoto = {
|
||||||
id: number;
|
id: number;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
fullUrl?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
likes: number;
|
likes: number;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
ingestSource?: string | null;
|
ingestSource?: string | null;
|
||||||
@@ -37,6 +39,9 @@ function normalizeImageUrl(src?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
|
||||||
|
if (cleanPath.startsWith('api/')) {
|
||||||
|
return `/${cleanPath}`;
|
||||||
|
}
|
||||||
if (!cleanPath.startsWith('storage/')) {
|
if (!cleanPath.startsWith('storage/')) {
|
||||||
cleanPath = `storage/${cleanPath}`;
|
cleanPath = `storage/${cleanPath}`;
|
||||||
}
|
}
|
||||||
@@ -47,18 +52,29 @@ function normalizeImageUrl(src?: string | null) {
|
|||||||
function mapPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
function mapPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
||||||
const id = Number(photo.id ?? 0);
|
const id = Number(photo.id ?? 0);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const imageUrl = normalizeImageUrl(
|
const fullUrl = normalizeImageUrl(
|
||||||
(photo.full_url as string | null | undefined)
|
(photo.full_url as string | null | undefined)
|
||||||
?? (photo.file_path 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_url as string | null | undefined)
|
||||||
?? (photo.thumbnail_path as string | null | undefined)
|
?? (photo.thumbnail_path as string | null | undefined)
|
||||||
?? (photo.url as string | null | undefined)
|
?? (photo.url as string | null | undefined)
|
||||||
?? (photo.image_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;
|
if (!imageUrl) return null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
fullUrl: fullUrl || null,
|
||||||
|
downloadUrl: downloadUrl || null,
|
||||||
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
||||||
createdAt: typeof photo.created_at === 'string' ? photo.created_at : null,
|
createdAt: typeof photo.created_at === 'string' ? photo.created_at : null,
|
||||||
ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null,
|
ingestSource: typeof photo.ingest_source === 'string' ? photo.ingest_source : null,
|
||||||
@@ -617,7 +633,7 @@ export default function PhotoLightboxScreen() {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
unstyled
|
unstyled
|
||||||
onPress={() => downloadPhoto(selected?.imageUrl ?? null, selected?.id ?? null)}
|
onPress={() => downloadPhoto(selected?.downloadUrl ?? selected?.fullUrl ?? selected?.imageUrl ?? null, selected?.id ?? null)}
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$2"
|
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.icon', '🙂');
|
||||||
$responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
|
$responseEn->assertJsonPath('data.0.emotion.color', '#FF00AA');
|
||||||
$responseEn->assertJsonPath('data.0.is_mine', true);
|
$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');
|
$etag = $responseEn->headers->get('ETag');
|
||||||
$this->assertNotEmpty($etag);
|
$this->assertNotEmpty($etag);
|
||||||
|
|||||||
Reference in New Issue
Block a user