Add mobile save sheet with same-origin photo downloads.
Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
MasonryGrid,
|
MasonryGrid,
|
||||||
} from '@/components/gallery/GalleryLayout';
|
} from '@/components/gallery/GalleryLayout';
|
||||||
import { useDownloads } from '@/components/gallery/useDownloads';
|
import { useDownloads } from '@/components/gallery/useDownloads';
|
||||||
|
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
|
||||||
import Lightbox from '@/components/Lightbox';
|
import Lightbox from '@/components/Lightbox';
|
||||||
import LoginModal from '@/components/LoginModal';
|
import LoginModal from '@/components/LoginModal';
|
||||||
import {
|
import {
|
||||||
@@ -53,12 +54,30 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
download: es ? 'Descargar' : 'Download',
|
download: es ? 'Descargar' : 'Download',
|
||||||
downloading: es ? 'Descargando…' : 'Downloading…',
|
downloading: es ? 'Descargando…' : 'Downloading…',
|
||||||
};
|
};
|
||||||
const downloadPhoto = (photo: Photo) =>
|
|
||||||
|
// On a phone a download cannot reach the photo library, so the button
|
||||||
|
// opens a sheet offering the shareable preview instead (SaveSheet).
|
||||||
|
// Desktop keeps downloading the original straight away.
|
||||||
|
const mobileSave = useMobileSave();
|
||||||
|
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
|
||||||
|
|
||||||
|
const downloadPhoto = (photo: Photo) => {
|
||||||
|
if (mobileSave) {
|
||||||
|
setSavePhoto({
|
||||||
|
id: photo.id,
|
||||||
|
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
|
||||||
|
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
|
||||||
|
previewSize: photo.previewSizeBytes,
|
||||||
|
originalSize: photo.sizeBytes,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
downloads.start({
|
downloads.start({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
url: photo.urls.original,
|
url: photo.urls.original,
|
||||||
filename: photo.originalFilename,
|
filename: photo.originalFilename,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Server-rendered public galleries need no client fetch. Everything else
|
// Server-rendered public galleries need no client fetch. Everything else
|
||||||
// (link/ticket/private) is fetched here with the share token and/or the
|
// (link/ticket/private) is fetched here with the share token and/or the
|
||||||
@@ -235,10 +254,12 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
// previewUrl is the download endpoint's preview: rendering and saving from
|
||||||
|
// the same URL is what lets "Save photo" be answered by the HTTP cache.
|
||||||
const lightboxItems = readyPhotos.map((p) => ({
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
previewUrl: p.urls.preview || p.urls.original,
|
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
|
||||||
downloadUrl: p.urls.original,
|
downloadUrl: p.urls.downloadOriginal || p.urls.original,
|
||||||
filename: p.originalFilename,
|
filename: p.originalFilename,
|
||||||
thumbUrl: p.urls.thumb,
|
thumbUrl: p.urls.thumb,
|
||||||
}));
|
}));
|
||||||
@@ -263,7 +284,11 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
// first photo. Falls back to a navy gradient with no image.
|
// first photo. Falls back to a navy gradient with no image.
|
||||||
const coverPhoto =
|
const coverPhoto =
|
||||||
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||||
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
// Same preview URL the lightbox uses, so the cover photo's bytes are
|
||||||
|
// fetched once for both.
|
||||||
|
const heroUrl = coverPhoto
|
||||||
|
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -338,15 +363,20 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
onNavigate={setLightboxIndex}
|
onNavigate={setLightboxIndex}
|
||||||
onDownload={(it) =>
|
onDownload={(it) => {
|
||||||
downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename })
|
const photo = readyPhotos.find((p) => p.id === it.id);
|
||||||
}
|
if (photo) downloadPhoto(photo);
|
||||||
|
}}
|
||||||
downloadingId={
|
downloadingId={
|
||||||
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
|
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
|
||||||
}
|
}
|
||||||
downloadLabels={downloadLabels}
|
downloadLabels={downloadLabels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Shared by both entry points: the tile button and the lightbox's.
|
||||||
|
Fetches nothing until it is open. */}
|
||||||
|
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
|
||||||
</GalleryContainer>
|
</GalleryContainer>
|
||||||
|
|
||||||
{/* Call to action: send attendees to their dashboard, everyone else to
|
{/* Call to action: send attendees to their dashboard, everyone else to
|
||||||
|
|||||||
@@ -179,10 +179,14 @@ export default function Lightbox({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{/* Nothing is layered over the image (the navigation buttons sit at
|
||||||
|
the edges), and neither -webkit-touch-callout nor -webkit-user-
|
||||||
|
select is suppressed here, so iOS long-press → "Save to Photos"
|
||||||
|
still works as a backup to the save sheet. Don't add select-none. */}
|
||||||
<img
|
<img
|
||||||
src={item.previewUrl}
|
src={item.previewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="max-w-[95vw] max-h-full object-contain select-none"
|
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -87,13 +87,15 @@ export default function PhotoTile({
|
|||||||
aria-busy={downloading}
|
aria-busy={downloading}
|
||||||
aria-label={downloading ? labels.downloading : labels.download}
|
aria-label={downloading ? labels.downloading : labels.download}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
'absolute bottom-2 right-2 flex p-2.5 md:p-2 rounded-full bg-black/50 text-white transition-opacity hover:bg-black/80 focus:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
// Touch devices have no hover to reveal it, and this is where the
|
||||||
|
// button matters most — it is the entry point to the save sheet.
|
||||||
downloading
|
downloading
|
||||||
? 'opacity-100 cursor-wait hover:bg-black/50'
|
? 'opacity-100 cursor-wait hover:bg-black/50'
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
: 'opacity-100 md:opacity-0 md:group-hover:opacity-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-4 h-4" />}
|
{downloading ? <Spinner /> : <ArrowDownTrayIcon className="w-5 h-5 md:w-4 md:h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ArrowDownTrayIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Spinner from '@/components/ui/Spinner';
|
||||||
|
|
||||||
|
// The mobile save flow. On a phone `<a download>` is a dead end for someone
|
||||||
|
// who wants the photo in their camera roll: iOS is WebKit everywhere, so it
|
||||||
|
// files the download under Files > Downloads and nothing else, and Android
|
||||||
|
// drops it in Downloads where Google Photos may or may not pick it up. The
|
||||||
|
// only route into the device gallery on either platform is the Web Share
|
||||||
|
// API, which hands the file to the native share sheet ("Save Image" on iOS,
|
||||||
|
// "Photos" on Android).
|
||||||
|
//
|
||||||
|
// So on mobile the download button opens this sheet instead of downloading,
|
||||||
|
// and the default row shares the 2048px preview — a file a phone screen
|
||||||
|
// cannot tell from the original, at a fraction of the bytes. The original
|
||||||
|
// stays one tap away for anyone who actually wants it.
|
||||||
|
|
||||||
|
export interface SavePhoto {
|
||||||
|
id: string;
|
||||||
|
/** Preview download URL — the same URL the lightbox renders, so this is
|
||||||
|
* usually answered from the HTTP cache rather than the network. */
|
||||||
|
previewUrl: string;
|
||||||
|
originalUrl: string;
|
||||||
|
/** Byte sizes from the gallery response, used to label the rows. */
|
||||||
|
previewSize?: number;
|
||||||
|
originalSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowKey = 'preview' | 'original';
|
||||||
|
type RowStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
interface RowState {
|
||||||
|
status: RowStatus;
|
||||||
|
/** 0..1, only tracked for the original's determinate progress bar. */
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: RowState = { status: 'idle', progress: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True on finger-first devices, i.e. where a plain download lands in
|
||||||
|
* Downloads instead of the photo library and this sheet is worth showing.
|
||||||
|
* Feature detection only — user agent strings lie, and iPad has been
|
||||||
|
* claiming to be a Mac for years.
|
||||||
|
*
|
||||||
|
* Deliberately NOT gated on `navigator.share`. Web Share only exists in a
|
||||||
|
* secure context, so requiring it here made the sheet disappear entirely on
|
||||||
|
* any plain-HTTP origin (a phone hitting the dev server over the LAN sees
|
||||||
|
* `navigator.share === undefined`) and on Firefox for Android, which has no
|
||||||
|
* file sharing. Both of those should still get the sheet — their rows just
|
||||||
|
* fall back to a download. Whether a given row shares or downloads is
|
||||||
|
* decided per file, at tap time, by canShareFile() below.
|
||||||
|
*/
|
||||||
|
export function useMobileSave(): boolean {
|
||||||
|
const [mobile, setMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Comma = OR. `hover: none` catches touch devices that report a fine
|
||||||
|
// pointer because a stylus is paired.
|
||||||
|
const mq = window.matchMedia('(pointer: coarse), (hover: none)');
|
||||||
|
const sync = () => setMobile(mq.matches);
|
||||||
|
sync();
|
||||||
|
mq.addEventListener('change', sync);
|
||||||
|
return () => mq.removeEventListener('change', sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShareFile(file: File): boolean {
|
||||||
|
return typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the server's Content-Disposition name (spanglish-<event>-<n>.jpg). */
|
||||||
|
function filenameFrom(header: string | null, fallback: string): string {
|
||||||
|
const match = header?.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i);
|
||||||
|
return match ? decodeURIComponent(match[1]) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined, locale: string): string | null {
|
||||||
|
if (!bytes || bytes <= 0) return null;
|
||||||
|
const mb = bytes / 1_000_000;
|
||||||
|
if (mb < 1) {
|
||||||
|
return `${Math.round(bytes / 1000).toLocaleString(locale)} kB`;
|
||||||
|
}
|
||||||
|
return `${mb.toLocaleString(locale, { maximumFractionDigits: 1 })} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a URL into a File. onProgress is only wired up when the caller
|
||||||
|
* wants a determinate bar — reading the body in chunks costs an extra copy,
|
||||||
|
* which is not worth it for the preview that is normally already cached.
|
||||||
|
*/
|
||||||
|
async function fetchAsFile(
|
||||||
|
url: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
onProgress?: (fraction: number) => void
|
||||||
|
): Promise<File> {
|
||||||
|
// No `cache` option: the default is what lets the lightbox's already
|
||||||
|
// rendered preview be reused instead of refetched.
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin', signal });
|
||||||
|
if (!res.ok) throw new Error(`Download failed (${res.status})`);
|
||||||
|
|
||||||
|
const type = res.headers.get('Content-Type') || 'image/jpeg';
|
||||||
|
const name = filenameFrom(res.headers.get('Content-Disposition'), 'spanglish-photo.jpg');
|
||||||
|
const total = Number(res.headers.get('Content-Length') || 0);
|
||||||
|
|
||||||
|
let blob: Blob;
|
||||||
|
if (onProgress && total > 0 && res.body) {
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let received = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
received += value.length;
|
||||||
|
onProgress(Math.min(1, received / total));
|
||||||
|
}
|
||||||
|
blob = new Blob(chunks as BlobPart[], { type });
|
||||||
|
} else {
|
||||||
|
blob = await res.blob();
|
||||||
|
}
|
||||||
|
return new File([blob], name, { type: blob.type || type });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback for browsers without file sharing: a plain download. */
|
||||||
|
function saveViaAnchor(file: File) {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = file.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveSheetProps {
|
||||||
|
/** The photo to save; null keeps the sheet closed. */
|
||||||
|
photo: SavePhoto | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SaveSheet({ photo, onClose }: SaveSheetProps) {
|
||||||
|
if (!photo) return null;
|
||||||
|
// Keyed by photo so a sheet opened for a different photo starts clean
|
||||||
|
// instead of inheriting the previous one's fetched file.
|
||||||
|
return <SaveSheetPanel key={photo.id} photo={photo} onClose={onClose} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveSheetPanel({ photo, onClose }: { photo: SavePhoto; onClose: () => void }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [rows, setRows] = useState<Record<RowKey, RowState>>({
|
||||||
|
preview: IDLE,
|
||||||
|
original: IDLE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolved files live in a ref as well as state: the tap handler has to
|
||||||
|
// read them *synchronously*. iOS rejects navigator.share() with
|
||||||
|
// NotAllowedError if anything is awaited between the tap and the call,
|
||||||
|
// so there is no chance to read them out of a promise first.
|
||||||
|
const files = useRef<Partial<Record<RowKey, File>>>({});
|
||||||
|
const aborter = useRef<AbortController>(new AbortController());
|
||||||
|
const closed = useRef(false);
|
||||||
|
|
||||||
|
const setRow = useCallback((key: RowKey, patch: Partial<RowState>) => {
|
||||||
|
if (closed.current) return;
|
||||||
|
setRows((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const prepare = useCallback(
|
||||||
|
async (key: RowKey): Promise<File | null> => {
|
||||||
|
setRow(key, { status: 'loading', progress: 0 });
|
||||||
|
try {
|
||||||
|
const file = await fetchAsFile(
|
||||||
|
key === 'preview' ? photo.previewUrl : photo.originalUrl,
|
||||||
|
aborter.current.signal,
|
||||||
|
// Only the original gets a determinate bar; the preview is
|
||||||
|
// normally cached and resolves before a spinner would even paint.
|
||||||
|
key === 'original' ? (progress) => setRow(key, { progress }) : undefined
|
||||||
|
);
|
||||||
|
files.current[key] = file;
|
||||||
|
setRow(key, { status: 'ready', progress: 1 });
|
||||||
|
return file;
|
||||||
|
} catch (err) {
|
||||||
|
// Aborting is how the sheet closes and how the user dismisses the
|
||||||
|
// native share sheet — neither is an error worth reporting.
|
||||||
|
if ((err as Error)?.name === 'AbortError') return null;
|
||||||
|
setRow(key, { status: 'error', progress: 0 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[photo.previewUrl, photo.originalUrl, setRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing is fetched until the sheet is open — not on lightbox open, not
|
||||||
|
// on swipe, not on scroll. Browsing the whole gallery without tapping
|
||||||
|
// save costs exactly the thumbs and previews already on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
// The controller is created here, not at render: closing the sheet
|
||||||
|
// aborts it, so a re-run of this effect (React StrictMode double-invokes
|
||||||
|
// it in development) has to start from a fresh, unaborted one.
|
||||||
|
closed.current = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
aborter.current = controller;
|
||||||
|
void prepare('preview');
|
||||||
|
return () => {
|
||||||
|
closed.current = true;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [prepare]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const deliver = (file: File) => {
|
||||||
|
if (canShareFile(file)) {
|
||||||
|
// Called with nothing awaited in front of it — see the ref above.
|
||||||
|
navigator
|
||||||
|
.share({ files: [file] })
|
||||||
|
.then(() => onClose())
|
||||||
|
.catch((err: Error) => {
|
||||||
|
// AbortError just means the share sheet was dismissed.
|
||||||
|
if (err?.name !== 'AbortError') saveViaAnchor(file);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveViaAnchor(file);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRowTap = (key: RowKey) => {
|
||||||
|
const ready = files.current[key];
|
||||||
|
if (ready) {
|
||||||
|
deliver(ready);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows[key].status === 'loading') return;
|
||||||
|
// Not fetched yet (or it failed): fetch now and stop. The row goes back
|
||||||
|
// to tappable when it resolves, so the share happens on a fresh
|
||||||
|
// gesture rather than a stale one iOS would refuse.
|
||||||
|
void prepare(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
preview: formatBytes(photo.previewSize, locale),
|
||||||
|
original: formatBytes(photo.originalSize, locale),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex flex-col justify-end"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('gallery.save.sheetTitle')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('gallery.save.cancel')}
|
||||||
|
className="absolute inset-0 bg-black/60 motion-safe:animate-sheet-fade"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className="relative w-full sm:max-w-md sm:mx-auto bg-white rounded-t-2xl sm:rounded-2xl sm:mb-4 shadow-xl p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] motion-safe:animate-sheet-rise">
|
||||||
|
<div className="mx-auto mb-2 mt-1 h-1 w-10 rounded-full bg-gray-300" aria-hidden />
|
||||||
|
|
||||||
|
<SaveRow
|
||||||
|
icon={<PhotoIcon className="w-6 h-6 text-primary-dark" />}
|
||||||
|
label={t('gallery.save.photo')}
|
||||||
|
hint={t('gallery.save.photoHint')}
|
||||||
|
size={sizes.preview}
|
||||||
|
state={rows.preview}
|
||||||
|
statusLabels={t}
|
||||||
|
onTap={() => onRowTap('preview')}
|
||||||
|
/>
|
||||||
|
<SaveRow
|
||||||
|
icon={<ArrowDownTrayIcon className="w-6 h-6 text-primary-dark" />}
|
||||||
|
label={t('gallery.save.original')}
|
||||||
|
hint={t('gallery.save.originalHint')}
|
||||||
|
size={sizes.original}
|
||||||
|
state={rows.original}
|
||||||
|
statusLabels={t}
|
||||||
|
showProgress
|
||||||
|
onTap={() => onRowTap('original')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-1 w-full rounded-xl py-3 text-center font-medium text-gray-600 hover:bg-gray-50 active:bg-gray-100"
|
||||||
|
>
|
||||||
|
{t('gallery.save.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveRow({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
size,
|
||||||
|
state,
|
||||||
|
statusLabels,
|
||||||
|
showProgress,
|
||||||
|
onTap,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
size: string | null;
|
||||||
|
state: RowState;
|
||||||
|
statusLabels: (key: string) => string;
|
||||||
|
showProgress?: boolean;
|
||||||
|
onTap: () => void;
|
||||||
|
}) {
|
||||||
|
const loading = state.status === 'loading';
|
||||||
|
// The subtitle carries the row's state, so a slow fetch explains itself
|
||||||
|
// without the row ever becoming untappable for good.
|
||||||
|
const subtitle =
|
||||||
|
state.status === 'loading'
|
||||||
|
? statusLabels('gallery.save.preparing')
|
||||||
|
: state.status === 'error'
|
||||||
|
? statusLabels('gallery.save.failed')
|
||||||
|
: [hint, size].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTap}
|
||||||
|
aria-busy={loading}
|
||||||
|
className="relative w-full overflow-hidden rounded-xl px-4 py-3 text-left flex items-center gap-3 hover:bg-gray-50 active:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow"
|
||||||
|
>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{loading ? (
|
||||||
|
// Spinner defaults to the white ring used over photos; this one
|
||||||
|
// sits on the sheet's white surface.
|
||||||
|
<Spinner className="w-6 h-6 border-gray-300 border-t-primary-dark" />
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium text-primary-dark">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`block text-sm ${state.status === 'error' ? 'text-red-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{showProgress && loading && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-1 bg-gray-200" aria-hidden>
|
||||||
|
<span
|
||||||
|
className="block h-full bg-primary-yellow transition-[width] duration-150"
|
||||||
|
style={{ width: `${Math.round(state.progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -367,5 +367,20 @@
|
|||||||
"title": "TikTok",
|
"title": "TikTok",
|
||||||
"subtitle": "Videos & fun content"
|
"subtitle": "Videos & fun content"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"save": {
|
||||||
|
"sheetTitle": "Save photo",
|
||||||
|
"photo": "Save photo",
|
||||||
|
"photoHint": "Best for phone",
|
||||||
|
"original": "Download original",
|
||||||
|
"originalHint": "Full quality",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"preparing": "Preparing…",
|
||||||
|
"ready": "Ready — tap to save",
|
||||||
|
"failed": "Could not prepare it. Tap to retry.",
|
||||||
|
"download": "Download",
|
||||||
|
"downloading": "Downloading…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,5 +367,20 @@
|
|||||||
"title": "TikTok",
|
"title": "TikTok",
|
||||||
"subtitle": "Videos y contenido divertido"
|
"subtitle": "Videos y contenido divertido"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"gallery": {
|
||||||
|
"save": {
|
||||||
|
"sheetTitle": "Guardar foto",
|
||||||
|
"photo": "Guardar foto",
|
||||||
|
"photoHint": "Ideal para el teléfono",
|
||||||
|
"original": "Descargar original",
|
||||||
|
"originalHint": "Calidad completa",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"preparing": "Preparando…",
|
||||||
|
"ready": "Lista: toca para guardar",
|
||||||
|
"failed": "No se pudo preparar. Toca para reintentar.",
|
||||||
|
"download": "Descargar",
|
||||||
|
"downloading": "Descargando…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface Photo {
|
|||||||
originalFilename?: string;
|
originalFilename?: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
|
/** Size of the preview variant; absent until the server has measured it. */
|
||||||
|
previewSizeBytes?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
takenAt?: string;
|
takenAt?: string;
|
||||||
@@ -51,6 +53,15 @@ export interface Photo {
|
|||||||
thumb?: string;
|
thumb?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
original: string;
|
original: string;
|
||||||
|
/**
|
||||||
|
* Download endpoints. Unlike `preview`/`original` these always stream
|
||||||
|
* same-origin instead of redirecting to S3, so fetch() can read them
|
||||||
|
* into a Blob (needed by the mobile share sheet) without CORS.
|
||||||
|
* `download` serves the preview and is what the lightbox renders, so
|
||||||
|
* saving it hits the HTTP cache instead of the network.
|
||||||
|
*/
|
||||||
|
download?: string;
|
||||||
|
downloadOriginal: string;
|
||||||
};
|
};
|
||||||
// Upload responses only: these bytes were already in the gallery, so nothing
|
// Upload responses only: these bytes were already in the gallery, so nothing
|
||||||
// was stored and the rest of this object describes the existing photo.
|
// was stored and the rest of this object describes the existing photo.
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ module.exports = {
|
|||||||
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
'card': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||||
'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
'card-hover': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
},
|
},
|
||||||
|
// Entrance for the mobile save sheet (components/gallery/SaveSheet).
|
||||||
|
keyframes: {
|
||||||
|
'sheet-fade': { from: { opacity: '0' }, to: { opacity: '1' } },
|
||||||
|
'sheet-rise': {
|
||||||
|
from: { transform: 'translateY(100%)' },
|
||||||
|
to: { transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'sheet-fade': 'sheet-fade 150ms ease-out',
|
||||||
|
'sheet-rise': 'sheet-rise 200ms ease-out',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
@@ -750,3 +750,177 @@ func TestSlugCollision(t *testing.T) {
|
|||||||
t.Fatalf("default visibility: %s", a.Visibility)
|
t.Fatalf("default visibility: %s", a.Visibility)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDownloadEndpoint pins the guarantees the mobile save sheet is built
|
||||||
|
// on: the bytes arrive same-origin (never a redirect), typed as an image,
|
||||||
|
// named after the event, sized up front and cacheable forever.
|
||||||
|
func TestDownloadEndpoint(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
admin := e.makeToken(t, uAdmin)
|
||||||
|
g := createGallery(t, e, admin, map[string]any{
|
||||||
|
"title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid,
|
||||||
|
})
|
||||||
|
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||||
|
if processQueue(t, e, p.ID).Status != "ready" {
|
||||||
|
t.Fatal("processing failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := "/api/photos/files/" + p.ID + "/download"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, path, wantDisposition, wantFilename string
|
||||||
|
}{
|
||||||
|
// No ?size at all must behave exactly like ?size=preview.
|
||||||
|
{"default", base, "inline", "spanglish-fiesta-1.jpg"},
|
||||||
|
{"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"},
|
||||||
|
{"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := e.request(t, "GET", tc.path, "", nil)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||||
|
t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct)
|
||||||
|
}
|
||||||
|
if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" {
|
||||||
|
t.Fatalf("content length %q, body %d bytes", n, w.Body.Len())
|
||||||
|
}
|
||||||
|
cd := w.Header().Get("Content-Disposition")
|
||||||
|
if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) {
|
||||||
|
t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename)
|
||||||
|
}
|
||||||
|
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
||||||
|
t.Fatalf("cache-control %q", cc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each size serves its own variant byte-for-byte. (No size comparison
|
||||||
|
// between the two here: the fixture is a synthetic 800x600 image, so its
|
||||||
|
// 2048px preview re-encode is larger than the "original" — which says
|
||||||
|
// nothing about the multi-megapixel photos the sheet exists for.)
|
||||||
|
prev := e.request(t, "GET", base, "", nil)
|
||||||
|
orig := e.request(t, "GET", base+"?size=original", "", nil)
|
||||||
|
if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) {
|
||||||
|
t.Fatal("download?size=preview must serve the same bytes as the preview variant")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) {
|
||||||
|
t.Fatal("download?size=original must serve the same bytes as the original variant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown size is not a silent fallback.
|
||||||
|
if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 {
|
||||||
|
t.Fatalf("unknown size: want 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gallery response labels both rows without any extra request.
|
||||||
|
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
|
||||||
|
got := detail.Photos[0]
|
||||||
|
if got.PreviewSizeBytes != int64(prev.Body.Len()) {
|
||||||
|
t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len())
|
||||||
|
}
|
||||||
|
if got.SizeBytes != int64(orig.Body.Len()) {
|
||||||
|
t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len())
|
||||||
|
}
|
||||||
|
if got.URLs.Download != base {
|
||||||
|
t.Fatalf("download url %q, want %q", got.URLs.Download, base)
|
||||||
|
}
|
||||||
|
if got.URLs.DownloadOriginal != base+"?size=original" {
|
||||||
|
t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A restricted gallery's download must obey the same token check as every
|
||||||
|
// other byte-serving route, and must not become shared-cacheable.
|
||||||
|
func TestDownloadRespectsAccess(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
admin := e.makeToken(t, uAdmin)
|
||||||
|
g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"})
|
||||||
|
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||||
|
if processQueue(t, e, p.ID).Status != "ready" {
|
||||||
|
t.Fatal("processing failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
|
||||||
|
url := detail.Photos[0].URLs.Download
|
||||||
|
if !strings.Contains(url, "?token=v1.") {
|
||||||
|
t.Fatalf("private download URL should carry a view token: %s", url)
|
||||||
|
}
|
||||||
|
// ?size and ?token have to coexist on the original's URL.
|
||||||
|
if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") {
|
||||||
|
t.Fatalf("private original download URL: %s", o)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := e.request(t, "GET", url, "", nil)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("view token fetch: want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
// Restricted galleries stay out of shared caches; the browser's own
|
||||||
|
// cache (which is what the save sheet reuses) still applies.
|
||||||
|
if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
|
||||||
|
t.Fatalf("cache-control %q must not be public for a private gallery", cc)
|
||||||
|
}
|
||||||
|
bare := strings.SplitN(url, "?", 2)[0]
|
||||||
|
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
|
||||||
|
t.Fatalf("anon download without token: want 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 {
|
||||||
|
t.Fatalf("anon original download without token: want 403, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos processed before preview_size_bytes existed have no stored size.
|
||||||
|
// The first gallery view measures them and writes the result back, so no
|
||||||
|
// separate backfill command is needed for an existing library.
|
||||||
|
func TestPreviewSizeBackfilledOnRead(t *testing.T) {
|
||||||
|
e := setup(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
admin := e.makeToken(t, uAdmin)
|
||||||
|
g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"})
|
||||||
|
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||||
|
if processQueue(t, e, p.ID).Status != "ready" {
|
||||||
|
t.Fatal("processing failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind to what an pre-migration row looks like.
|
||||||
|
if _, err := e.db.ExecContext(ctx, e.db.Rebind(
|
||||||
|
"UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if before, err := e.db.GetPhoto(ctx, p.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if before.PreviewSizeBytes != 0 {
|
||||||
|
t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len()
|
||||||
|
got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
|
||||||
|
if got.Photos[0].PreviewSizeBytes != int64(served) {
|
||||||
|
t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served)
|
||||||
|
}
|
||||||
|
// …and it was persisted, so the next view costs no Stat.
|
||||||
|
after, err := e.db.GetPhoto(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.PreviewSizeBytes != int64(served) {
|
||||||
|
t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Originals uploaded as HEIC or PNG must keep their real type and extension,
|
||||||
|
// and a row with a missing/generic type must still go out as an image.
|
||||||
|
func TestDownloadContentTypeNeverOctetStream(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ ct, name, want string }{
|
||||||
|
{"image/heic", "IMG_1.HEIC", "image/heic"},
|
||||||
|
{"image/png", "shot.png", "image/png"},
|
||||||
|
{"application/octet-stream", "IMG_2.heic", "image/heic"},
|
||||||
|
{"application/octet-stream", "scan.PNG", "image/png"},
|
||||||
|
{"", "photo.jpeg", "image/jpeg"},
|
||||||
|
{"", "", "image/jpeg"},
|
||||||
|
} {
|
||||||
|
if got := imageContentType(tc.ct, tc.name); got != tc.want {
|
||||||
|
t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ type photoURLs struct {
|
|||||||
Thumb string `json:"thumb,omitempty"`
|
Thumb string `json:"thumb,omitempty"`
|
||||||
Preview string `json:"preview,omitempty"`
|
Preview string `json:"preview,omitempty"`
|
||||||
Original string `json:"original"`
|
Original string `json:"original"`
|
||||||
|
// Download / DownloadOriginal hit the download endpoint (files.go), which
|
||||||
|
// always streams the bytes same-origin instead of redirecting to S3, so a
|
||||||
|
// fetch() can read them into a Blob without CORS. Download serves the
|
||||||
|
// preview variant and doubles as the lightbox's <img> source, so the save
|
||||||
|
// fetch is answered from the HTTP cache rather than the network.
|
||||||
|
Download string `json:"download,omitempty"`
|
||||||
|
DownloadOriginal string `json:"downloadOriginal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type photoJSON struct {
|
type photoJSON struct {
|
||||||
@@ -51,6 +58,9 @@ type photoJSON struct {
|
|||||||
OriginalFilename string `json:"originalFilename,omitempty"`
|
OriginalFilename string `json:"originalFilename,omitempty"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
SizeBytes int64 `json:"sizeBytes"`
|
SizeBytes int64 `json:"sizeBytes"`
|
||||||
|
// PreviewSizeBytes lets the mobile save sheet label both of its rows with
|
||||||
|
// a real file size without fetching anything. Omitted while unknown.
|
||||||
|
PreviewSizeBytes int64 `json:"previewSizeBytes,omitempty"`
|
||||||
Width int `json:"width,omitempty"`
|
Width int `json:"width,omitempty"`
|
||||||
Height int `json:"height,omitempty"`
|
Height int `json:"height,omitempty"`
|
||||||
TakenAt string `json:"takenAt,omitempty"`
|
TakenAt string `json:"takenAt,omitempty"`
|
||||||
@@ -91,6 +101,22 @@ func fileURL(photoID, variant, token string) string {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadURL builds the download endpoint's URL. size is left off for the
|
||||||
|
// preview, which the endpoint serves by default — a shorter, stabler string
|
||||||
|
// for the URL the lightbox also renders.
|
||||||
|
func downloadURL(photoID, size, token string) string {
|
||||||
|
u := "/api/photos/files/" + photoID + "/download"
|
||||||
|
sep := "?"
|
||||||
|
if size != "" && size != downloadSizePreview {
|
||||||
|
u += "?size=" + size
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
u += sep + "token=" + token
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
|
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
|
||||||
out := photoJSON{
|
out := photoJSON{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
@@ -101,16 +127,21 @@ func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON
|
|||||||
SizeBytes: p.SizeBytes,
|
SizeBytes: p.SizeBytes,
|
||||||
Width: p.Width,
|
Width: p.Width,
|
||||||
Height: p.Height,
|
Height: p.Height,
|
||||||
|
PreviewSizeBytes: p.PreviewSizeBytes,
|
||||||
TakenAt: isoTime(p.TakenAt),
|
TakenAt: isoTime(p.TakenAt),
|
||||||
Status: p.Status,
|
Status: p.Status,
|
||||||
CreatedAt: isoTime(p.CreatedAt),
|
CreatedAt: isoTime(p.CreatedAt),
|
||||||
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
|
URLs: photoURLs{
|
||||||
|
Original: fileURL(p.ID, "original", token),
|
||||||
|
DownloadOriginal: downloadURL(p.ID, downloadSizeOriginal, token),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if p.ThumbKey != "" {
|
if p.ThumbKey != "" {
|
||||||
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
|
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
|
||||||
}
|
}
|
||||||
if p.PreviewKey != "" {
|
if p.PreviewKey != "" {
|
||||||
out.URLs.Preview = fileURL(p.ID, "preview", token)
|
out.URLs.Preview = fileURL(p.ID, "preview", token)
|
||||||
|
out.URLs.Download = downloadURL(p.ID, downloadSizePreview, token)
|
||||||
}
|
}
|
||||||
if admin {
|
if admin {
|
||||||
out.LastError = p.LastError
|
out.LastError = p.LastError
|
||||||
|
|||||||
@@ -7,13 +7,24 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const presignExpiry = 15 * time.Minute
|
const presignExpiry = 15 * time.Minute
|
||||||
|
|
||||||
|
// Sizes the download endpoint offers. Preview is the default because it is
|
||||||
|
// what a phone can actually save to its photo library: iOS and Android only
|
||||||
|
// reach the gallery through the share sheet, and the share sheet is worth
|
||||||
|
// handing a 2048px JPEG rather than a multi-megabyte original.
|
||||||
|
const (
|
||||||
|
downloadSizePreview = "preview"
|
||||||
|
downloadSizeOriginal = "original"
|
||||||
|
)
|
||||||
|
|
||||||
// serveFile delivers photo bytes after re-running the gallery access check.
|
// serveFile delivers photo bytes after re-running the gallery access check.
|
||||||
// S3: 302 to a short-lived presigned URL; local: streamed directly.
|
// S3: 302 to a short-lived presigned URL; local: streamed directly.
|
||||||
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -22,23 +33,10 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "Not Found")
|
writeError(w, http.StatusNotFound, "Not Found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
p, _, ok := s.authorizedPhoto(w, r)
|
||||||
if err != nil {
|
if !ok {
|
||||||
writeStoreError(w, err, "Photo not found")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
|
|
||||||
if err != nil {
|
|
||||||
writeStoreError(w, err, "Photo not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user := s.optionalUser(r)
|
|
||||||
token := r.URL.Query().Get("token")
|
|
||||||
if denial := s.authorize(r, g, user, token); denial != nil {
|
|
||||||
writeError(w, denial.status, denial.msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isAdmin := user != nil && user.IsAdmin()
|
|
||||||
|
|
||||||
var key, contentType, downloadName string
|
var key, contentType, downloadName string
|
||||||
switch variant {
|
switch variant {
|
||||||
@@ -58,12 +56,6 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "Not ready")
|
writeError(w, http.StatusNotFound, "Not ready")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Non-ready originals stay admin-only so uploads that fail processing
|
|
||||||
// never leak to viewers.
|
|
||||||
if p.Status != "ready" && !isAdmin {
|
|
||||||
writeError(w, http.StatusNotFound, "Not ready")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
|
if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil {
|
||||||
http.Redirect(w, r, url, http.StatusFound)
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
@@ -100,6 +92,155 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serveDownload backs the gallery's save/download actions. It differs from
|
||||||
|
// serveFile in three ways that the mobile save flow depends on:
|
||||||
|
//
|
||||||
|
// - It always streams. serveFile 302s to S3 when presigning is available,
|
||||||
|
// and a cross-origin redirect is opaque to fetch() — the client could
|
||||||
|
// never read the bytes into a Blob to hand to navigator.share().
|
||||||
|
// - It names the file (spanglish-<event>-<n>.jpg) and always declares a
|
||||||
|
// real image/* type, because iOS only offers "Save Image" in the share
|
||||||
|
// sheet for something it recognises as an image.
|
||||||
|
// - It caches for a year as immutable. ?size=preview is also the URL the
|
||||||
|
// lightbox renders, so tapping save re-reads the bytes already on screen
|
||||||
|
// out of the HTTP cache instead of fetching them again.
|
||||||
|
func (s *Server) serveDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
size := r.URL.Query().Get("size")
|
||||||
|
if size == "" {
|
||||||
|
size = downloadSizePreview
|
||||||
|
}
|
||||||
|
if size != downloadSizePreview && size != downloadSizeOriginal {
|
||||||
|
writeError(w, http.StatusNotFound, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, g, ok := s.authorizedPhoto(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, contentType := p.PreviewKey, "image/jpeg"
|
||||||
|
if size == downloadSizeOriginal {
|
||||||
|
key, contentType = p.OriginalKey, imageContentType(p.ContentType, p.OriginalFilename)
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusNotFound, "Not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, n, err := s.storage.Open(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) || errors.Is(err, storage.ErrNotExist) {
|
||||||
|
writeError(w, http.StatusNotFound, "Not Found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Error: open %s: %v", key, err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", n))
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
// The preview is rendered as an <img> as well as saved, so it is marked
|
||||||
|
// inline; only the original is a pure download. Either way the filename
|
||||||
|
// travels with it, which is what a bare <a download> fallback and the
|
||||||
|
// Android share sheet display.
|
||||||
|
disposition := "inline"
|
||||||
|
if size == downloadSizeOriginal {
|
||||||
|
disposition = "attachment"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q",
|
||||||
|
disposition, s.downloadFilename(r, g, p, size)))
|
||||||
|
// Objects are immutable (a new upload writes a new key), so the response
|
||||||
|
// can be cached for as long as the browser will keep it. Restricted
|
||||||
|
// galleries stay out of shared caches: access is re-checked per request
|
||||||
|
// here, and only the browser that passed the check may reuse the bytes.
|
||||||
|
scope := "private"
|
||||||
|
if g.Visibility == store.VisibilityPublic {
|
||||||
|
scope = "public"
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable")
|
||||||
|
|
||||||
|
if _, err := io.Copy(w, reader); err != nil {
|
||||||
|
// Routine on mobile: the client aborts the fetch when the save sheet
|
||||||
|
// is cancelled, which lands here as a broken pipe.
|
||||||
|
log.Printf("stream %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authorizedPhoto loads the photo and its gallery and re-runs the access
|
||||||
|
// check every byte-serving handler owes (PLAN.md §7). It writes the error
|
||||||
|
// response itself; ok=false means the caller is done.
|
||||||
|
func (s *Server) authorizedPhoto(w http.ResponseWriter, r *http.Request) (store.Photo, store.Gallery, bool) {
|
||||||
|
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err, "Photo not found")
|
||||||
|
return store.Photo{}, store.Gallery{}, false
|
||||||
|
}
|
||||||
|
g, err := s.db.GetGallery(r.Context(), p.GalleryID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, err, "Photo not found")
|
||||||
|
return store.Photo{}, store.Gallery{}, false
|
||||||
|
}
|
||||||
|
user := s.optionalUser(r)
|
||||||
|
if denial := s.authorize(r, g, user, r.URL.Query().Get("token")); denial != nil {
|
||||||
|
writeError(w, denial.status, denial.msg)
|
||||||
|
return store.Photo{}, store.Gallery{}, false
|
||||||
|
}
|
||||||
|
// Non-ready originals stay admin-only so uploads that fail processing
|
||||||
|
// never leak to viewers.
|
||||||
|
if p.Status != "ready" && !(user != nil && user.IsAdmin()) {
|
||||||
|
writeError(w, http.StatusNotFound, "Not ready")
|
||||||
|
return store.Photo{}, store.Gallery{}, false
|
||||||
|
}
|
||||||
|
return p, g, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFilename names the saved file after the event it came from rather
|
||||||
|
// than the photographer's IMG_1234.JPG: spanglish-<event-slug>-<n>.<ext>,
|
||||||
|
// numbered from 1 in gallery order. Standalone galleries use their own slug.
|
||||||
|
func (s *Server) downloadFilename(r *http.Request, g store.Gallery, p store.Photo, size string) string {
|
||||||
|
slug := g.Slug
|
||||||
|
if g.EventID != "" {
|
||||||
|
if ev, err := s.db.GetEventSummary(r.Context(), g.EventID); err == nil && ev.Slug != "" {
|
||||||
|
slug = ev.Slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ext := ".jpg"
|
||||||
|
if size == downloadSizeOriginal {
|
||||||
|
if e := extForContentType(p.ContentType); e != "" {
|
||||||
|
ext = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("spanglish-%s-%d%s", slug, p.Position+1, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageContentType keeps the download endpoint's Content-Type in the image/*
|
||||||
|
// family. An original stored with a missing or generic type would otherwise
|
||||||
|
// go out as application/octet-stream, and iOS drops "Save Image" from the
|
||||||
|
// share sheet for anything it cannot see as an image.
|
||||||
|
func imageContentType(ct, filename string) string {
|
||||||
|
if strings.HasPrefix(ct, "image/") {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(filename, "."); i >= 0 {
|
||||||
|
switch strings.ToLower(filename[i:]) {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".heic", ".heif":
|
||||||
|
return "image/heic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
func extForContentType(ct string) string {
|
func extForContentType(ct string) string {
|
||||||
switch ct {
|
switch ct {
|
||||||
case "image/jpeg":
|
case "image/jpeg":
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User)
|
|||||||
writeStoreError(w, err, "")
|
writeStoreError(w, err, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.fillPreviewSizes(r.Context(), photos)
|
||||||
urlToken := s.viewTokenFor(g)
|
urlToken := s.viewTokenFor(g)
|
||||||
out := make([]photoJSON, 0, len(photos))
|
out := make([]photoJSON, 0, len(photos))
|
||||||
for _, p := range photos {
|
for _, p := range photos {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func (s *Server) respondGalleryView(w http.ResponseWriter, r *http.Request, g st
|
|||||||
writeStoreError(w, err, "")
|
writeStoreError(w, err, "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.fillPreviewSizes(r.Context(), photos)
|
||||||
out := make([]photoJSON, 0, len(photos))
|
out := make([]photoJSON, 0, len(photos))
|
||||||
for _, p := range photos {
|
for _, p := range photos {
|
||||||
out = append(out, s.photoToJSON(p, urlToken, false))
|
out = append(out, s.photoToJSON(p, urlToken, false))
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries)
|
mux.HandleFunc("GET /api/photos/public/galleries", s.listPublicGalleries)
|
||||||
mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery)
|
mux.HandleFunc("GET /api/photos/public/galleries/{slug}", s.getPublicGallery)
|
||||||
mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery)
|
mux.HandleFunc("GET /api/photos/public/events/{eventSlug}/gallery", s.getEventGallery)
|
||||||
|
// The literal "download" segment takes precedence over {variant} under
|
||||||
|
// ServeMux's specificity rules, so the two can share the prefix.
|
||||||
|
mux.HandleFunc("GET /api/photos/files/{photoId}/download", s.serveDownload)
|
||||||
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
|
mux.HandleFunc("GET /api/photos/files/{photoId}/{variant}", s.serveFile)
|
||||||
|
|
||||||
return s.withCommon(mux)
|
return s.withCommon(mux)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statConcurrency bounds the one-off Stat burst below; a gallery of ~70
|
||||||
|
// photos then costs a handful of round-trips against S3, once ever.
|
||||||
|
const statConcurrency = 8
|
||||||
|
|
||||||
|
// fillPreviewSizes measures the preview objects of photos whose
|
||||||
|
// preview_size_bytes is still unset and writes the result back, updating the
|
||||||
|
// slice in place.
|
||||||
|
//
|
||||||
|
// The worker records the size when it generates the preview, so this only
|
||||||
|
// ever fires for rows that predate the column (or arrived via `photo-api
|
||||||
|
// sync`). It is a self-healing cache fill rather than a migration step: the
|
||||||
|
// first gallery view after deploy pays for one Stat per photo, every view
|
||||||
|
// after that reads the stored value. Errors are logged and swallowed — a
|
||||||
|
// missing size costs the save sheet its row label, which is not worth
|
||||||
|
// failing a gallery response over.
|
||||||
|
func (s *Server) fillPreviewSizes(ctx context.Context, photos []store.Photo) {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, statConcurrency)
|
||||||
|
mu sync.Mutex
|
||||||
|
size = make(map[string]int64, len(photos))
|
||||||
|
)
|
||||||
|
for _, p := range photos {
|
||||||
|
if p.PreviewKey == "" || p.PreviewSizeBytes > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p store.Photo) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
n, err := s.storage.Stat(ctx, p.PreviewKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("preview size %s: %v", p.PreviewKey, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
size[p.ID] = n
|
||||||
|
mu.Unlock()
|
||||||
|
}(p)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if len(size) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range photos {
|
||||||
|
if n, ok := size[photos[i].ID]; ok {
|
||||||
|
photos[i].PreviewSizeBytes = n
|
||||||
|
if err := s.db.SetPreviewSize(ctx, photos[i].ID, n); err != nil {
|
||||||
|
log.Printf("store preview size %s: %v", photos[i].ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ func syncEnv(t *testing.T) (config.Config, *store.DB, *stubS3, []string) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.MarkPhotoReady(ctx, photoID, thumbKey, previewKey, 100, 80, now); err != nil {
|
if err := db.MarkPhotoReady(ctx, photoID, thumbKey, previewKey, 7, 100, 80, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return cfg, db, stub, []string{origKey, thumbKey, previewKey}
|
return cfg, db, stub, []string{origKey, thumbKey, previewKey}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ type Photo struct {
|
|||||||
Height int
|
Height int
|
||||||
ThumbKey string
|
ThumbKey string
|
||||||
PreviewKey string
|
PreviewKey string
|
||||||
|
// PreviewSizeBytes is the size of the preview object, 0 when it has not
|
||||||
|
// been measured yet (rows written before the column existed).
|
||||||
|
PreviewSizeBytes int64
|
||||||
TakenAt time.Time
|
TakenAt time.Time
|
||||||
Status string
|
Status string
|
||||||
Attempts int
|
Attempts int
|
||||||
@@ -35,11 +38,11 @@ type Photo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
|
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
|
||||||
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at,
|
size_bytes, width, height, thumb_key, preview_key, preview_size_bytes, taken_at, status,
|
||||||
last_error, created_at, updated_at, checksum`
|
attempts, next_attempt_at, last_error, created_at, updated_at, checksum`
|
||||||
|
|
||||||
func scanPhoto(s scanner) (Photo, error) {
|
func scanPhoto(s scanner) (Photo, error) {
|
||||||
var v [19]any
|
var v [20]any
|
||||||
dest := make([]any, len(v))
|
dest := make([]any, len(v))
|
||||||
for i := range v {
|
for i := range v {
|
||||||
dest[i] = &v[i]
|
dest[i] = &v[i]
|
||||||
@@ -59,14 +62,15 @@ func scanPhoto(s scanner) (Photo, error) {
|
|||||||
Height: int(asInt(v[8])),
|
Height: int(asInt(v[8])),
|
||||||
ThumbKey: asString(v[9]),
|
ThumbKey: asString(v[9]),
|
||||||
PreviewKey: asString(v[10]),
|
PreviewKey: asString(v[10]),
|
||||||
TakenAt: asTime(v[11]),
|
PreviewSizeBytes: asInt(v[11]),
|
||||||
Status: asString(v[12]),
|
TakenAt: asTime(v[12]),
|
||||||
Attempts: int(asInt(v[13])),
|
Status: asString(v[13]),
|
||||||
NextAttemptAt: asTime(v[14]),
|
Attempts: int(asInt(v[14])),
|
||||||
LastError: asString(v[15]),
|
NextAttemptAt: asTime(v[15]),
|
||||||
CreatedAt: asTime(v[16]),
|
LastError: asString(v[16]),
|
||||||
UpdatedAt: asTime(v[17]),
|
CreatedAt: asTime(v[17]),
|
||||||
Checksum: asString(v[18]),
|
UpdatedAt: asTime(v[18]),
|
||||||
|
Checksum: asString(v[19]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,17 +327,26 @@ func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
|
|||||||
return db.GetPhoto(ctx, id)
|
return db.GetPhoto(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, width, height int, takenAt time.Time) error {
|
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, previewSize int64, width, height int, takenAt time.Time) error {
|
||||||
var takenArg any
|
var takenArg any
|
||||||
if !takenAt.IsZero() {
|
if !takenAt.IsZero() {
|
||||||
takenArg = db.TimeArg(takenAt)
|
takenArg = db.TimeArg(takenAt)
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx, db.Rebind(`
|
_, err := db.ExecContext(ctx, db.Rebind(`
|
||||||
UPDATE photos_photos
|
UPDATE photos_photos
|
||||||
SET status = 'ready', thumb_key = ?, preview_key = ?, width = ?, height = ?, taken_at = ?,
|
SET status = 'ready', thumb_key = ?, preview_key = ?, preview_size_bytes = ?, width = ?,
|
||||||
last_error = NULL, updated_at = ?
|
height = ?, taken_at = ?, last_error = NULL, updated_at = ?
|
||||||
WHERE id = ?`),
|
WHERE id = ?`),
|
||||||
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), id)
|
thumbKey, previewKey, previewSize, width, height, takenArg, db.TimeArg(time.Now()), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPreviewSize records the measured size of an already-generated preview.
|
||||||
|
// Used to fill in rows processed before the column existed; failure to write
|
||||||
|
// is not worth failing a read over, so callers may ignore the error.
|
||||||
|
func (db *DB) SetPreviewSize(ctx context.Context, id string, size int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, db.Rebind(
|
||||||
|
"UPDATE photos_photos SET preview_size_bytes = ? WHERE id = ?"), size, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,14 +146,17 @@ func (w *Worker) process(ctx context.Context, p store.Photo) error {
|
|||||||
prefix := fmt.Sprintf("galleries/%s", p.GalleryID)
|
prefix := fmt.Sprintf("galleries/%s", p.GalleryID)
|
||||||
thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base)
|
thumbKey := fmt.Sprintf("%s/thumb/%s.jpg", prefix, base)
|
||||||
previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base)
|
previewKey := fmt.Sprintf("%s/preview/%s.jpg", prefix, base)
|
||||||
if err := w.upload(ctx, thumbKey, thumbPath); err != nil {
|
if _, err := w.upload(ctx, thumbKey, thumbPath); err != nil {
|
||||||
return fmt.Errorf("store thumb: %w", err)
|
return fmt.Errorf("store thumb: %w", err)
|
||||||
}
|
}
|
||||||
if err := w.upload(ctx, previewKey, previewPath); err != nil {
|
// The preview's size is recorded because the gallery response labels the
|
||||||
|
// mobile save sheet with it; measuring it here costs nothing.
|
||||||
|
previewSize, err := w.upload(ctx, previewKey, previewPath)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("store preview: %w", err)
|
return fmt.Errorf("store preview: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, res.Width, res.Height, res.TakenAt)
|
return w.db.MarkPhotoReady(ctx, p.ID, thumbKey, previewKey, previewSize, res.Width, res.Height, res.TakenAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) download(ctx context.Context, key, dst string) error {
|
func (w *Worker) download(ctx context.Context, key, dst string) error {
|
||||||
@@ -171,15 +174,19 @@ func (w *Worker) download(ctx context.Context, key, dst string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) upload(ctx context.Context, key, src string) error {
|
// upload stores a generated variant and reports how many bytes it holds.
|
||||||
|
func (w *Worker) upload(ctx context.Context, key, src string) (int64, error) {
|
||||||
f, err := os.Open(src)
|
f, err := os.Open(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
info, err := f.Stat()
|
info, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
return w.storage.Put(ctx, key, f, info.Size(), "image/jpeg")
|
if err := w.storage.Put(ctx, key, f, info.Size(), "image/jpeg"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Size(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Byte size of the generated preview variant, so the gallery response can
|
||||||
|
-- label the mobile save sheet's rows without the client HEADing every file.
|
||||||
|
-- NULL means "not measured yet" — rows predating this migration, until the
|
||||||
|
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
|
||||||
|
-- (No semicolons in these comments: the migration runner splits on them.)
|
||||||
|
ALTER TABLE photos_photos ADD COLUMN IF NOT EXISTS preview_size_bytes bigint;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Byte size of the generated preview variant, so the gallery response can
|
||||||
|
-- label the mobile save sheet's rows without the client HEADing every file.
|
||||||
|
-- NULL means "not measured yet" — rows predating this migration, until the
|
||||||
|
-- read path stats them once (see fillPreviewSizes in httpapi/sizes.go).
|
||||||
|
-- (No semicolons in these comments: the migration runner splits on them.)
|
||||||
|
ALTER TABLE photos_photos ADD COLUMN preview_size_bytes integer;
|
||||||
Reference in New Issue
Block a user