fix(gallery): no-store private JSON, and give guest uploads a real status

B6 -- seven gallery routes returned private, per-guest data with no
Cache-Control at all, relying on heuristic freshness. noStoreCache is mounted
per route rather than on the router, because the media routes set their own
private, max-age=1800/3600 and must keep it. Covered: /photos (own
likes/favourites/ratings, hidden photos for a client token), /people, /stats,
/verify-token/:token (an authorization decision -- a cached {valid:true}
outlives a rotated token), /show/:token/session (the response IS a credential;
it mints a gallery JWT), /show/:token/state and /download-jobs/:token (live
polls, where a cached "preparing" strands the caller). Deliberately untouched:
the photo/thumbnail/hero/preview and css-template routes, which set their own
caching, the binary downloads, and /info + /resolve, which are unauthenticated
public metadata rather than per-guest private.

ETag/304 revalidation is intact and pinned by a test: no-store stops the
browser retaining the body, not express agreeing an unchanged payload is
unchanged. That matters because the post-upload poll depends on it.

B7 -- the guest upload flow had no progress signal, so the UI polled the photo
list blind and gave up after 60s with no explanation. Adds
GET /:slug/uploads/status?ids=... rather than pending counts in the photos
payload: counts there are event-wide, so another guest's or the admin's stuck
upload would spin the notice forever and it could never say "your photo
failed".

Authorization: verifyGalleryAccess already resolves req.event from the
caller's token, and the query is scoped `.where('event_id', req.event.id)`, so
an id from another gallery matches no row -- neither a cross-event read nor an
existence oracle, since it returns all-zero counts rather than a 403/404 that
would confirm the id exists elsewhere. Slideshow tokens are denied (a kiosk
never uploads). Ids are pattern-validated, max 50. The response is counts
only: no filenames and specifically no processing_error strings, which can
carry internal paths. Not gated on allow_user_uploads, so an admin flipping
the toggle mid-flight does not strand an in-progress guest.

The frontend now finishes on the real terminal condition, refetches as each
photo lands rather than only at the end, shows a processing pill, and reports
real failures instead of silently timing out.

Refs testplan REPORT.md B6, B7.
This commit is contained in:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent 7c9baff751
commit a28f96b304
6 changed files with 511 additions and 48 deletions
+86 -14
View File
@@ -35,7 +35,8 @@ import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { galleryService } from '../../services/gallery.service';
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
@@ -255,9 +256,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
// the photo list only returns completed rows. A single immediate refetch
// therefore comes back with a byte-identical payload (which the browser is
// answered with a 304), so the guest saw their upload silently vanish until
// they hard-reloaded. Poll for a short while until the queued photos finish
// processing instead of refetching — or reloading the page — exactly once.
// they hard-reloaded.
//
// The first fix polled the photo list blind against a count baseline, which
// cannot tell a slow worker from a photo that failed processing — it just
// stopped after 60s with nothing on screen either way. Poll the upload
// group's real processing status instead (B7): it drives the "processing…"
// notice, refetches the grid as photos land rather than only at the end, and
// reports a failure instead of a silence.
const uploadRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null);
const stopUploadRefresh = () => {
if (uploadRefreshTimerRef.current) {
clearInterval(uploadRefreshTimerRef.current);
@@ -266,25 +274,87 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
};
useEffect(() => stopUploadRefresh, []);
const handleUploadComplete = (queuedCount = 1) => {
const handleUploadComplete = (uploadIds: string[] = []) => {
setShowUploadModal(false);
const baseline = data?.photos?.length ?? 0;
// Each file is processed independently, so stopping at the FIRST new photo
// leaves the rest of a multi-file upload hidden until a manual refresh —
// the very symptom this polling exists to prevent. Wait for all of them.
const target = baseline + Math.max(1, queuedCount);
const deadline = Date.now() + 60_000;
stopUploadRefresh();
// Nothing to follow (no id came back, e.g. every file failed on the wire).
// Refetch once rather than polling something unknowable.
if (uploadIds.length === 0) {
void refetch();
return;
}
setUploadProcessing({ complete: 0, total: uploadIds.length });
const deadline = Date.now() + 120_000;
let lastComplete = 0;
let inFlight = false;
const finish = async (announce?: () => void) => {
stopUploadRefresh();
setUploadProcessing(null);
await refetch();
announce?.();
};
const poll = async () => {
const result = await refetch();
if ((result.data?.photos?.length ?? 0) >= target || Date.now() > deadline) {
stopUploadRefresh();
// The interval keeps firing while a slow request is open; without this
// the requests stack up for the whole deadline.
if (inFlight) return;
inFlight = true;
try {
const status = await galleryService.getUploadStatus(slug, uploadIds);
setUploadProcessing({
complete: status.complete + status.failed,
total: status.total || uploadIds.length,
});
// Refetch as each photo lands, not only once the batch settles, so a
// large upload fills the grid progressively.
if (status.complete > lastComplete) {
lastComplete = status.complete;
void refetch();
}
if (status.pending === 0 && status.processing === 0) {
await finish(() => {
if (status.failed > 0) {
toast.error(t('upload.processingFailed', { count: status.failed }));
}
});
} else if (Date.now() > deadline) {
// Bounded. The worker is genuinely still running, so say that rather
// than leaving the guest with a grid that quietly never updated.
await finish(() => toast.info(t('upload.processingStillRunning')));
}
} catch {
// The status signal is a convenience — the photos are stored either
// way — so a failing status call degrades to the plain refetch.
await finish();
} finally {
inFlight = false;
}
};
uploadRefreshTimerRef.current = setInterval(poll, 2000);
poll();
void poll();
};
// The two layout branches below that render the photo grid have no shared
// wrapper, so the notice is shared as a value rather than as markup.
const uploadProcessingNotice = uploadProcessing ? (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-full bg-neutral-900/90 px-4 py-2 text-sm text-white shadow-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span>
{t('upload.processing')}{' '}
{t('upload.processingProgress', {
complete: uploadProcessing.complete,
total: uploadProcessing.total,
})}
</span>
</div>
) : null;
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
@@ -1393,6 +1463,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
onClose={() => setShowUploadModal(false)}
/>
)}
{uploadProcessingNotice}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
@@ -1800,6 +1871,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
onClose={() => setShowUploadModal(false)}
/>
)}
{uploadProcessingNotice}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
@@ -10,8 +10,9 @@ import { extensionsToMimeTypes, buildUploadAcceptString, extensionsToLabel } fro
interface UserPhotoUploadProps {
eventId: number;
categoryId: number | null | undefined;
/** Receives how many files the server accepted, so the caller can wait for all of them. */
onUploadComplete: (queuedCount: number) => void;
// Receives the upload-group ids the backend queued the files under, so the
// caller can poll their processing status instead of guessing (B7).
onUploadComplete: (uploadIds: string[]) => void;
onClose: () => void;
}
@@ -143,6 +144,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
setUploading(true);
let successCount = 0;
let failedCount = 0;
// The 202 hands back the id of the upload group the files were queued
// under. One request per file means one id per file; the gallery polls
// them together to know when the background worker is done (B7).
const uploadIds: string[] = [];
for (const file of files) {
const formData = new FormData();
@@ -152,7 +157,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
try {
await api.post(`/gallery/${eventId}/upload`, formData, {
const response = await api.post<{ upload_id?: string }>(`/gallery/${eventId}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -169,7 +174,11 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
},
});
// Request resolved → file fully processed by backend.
// Request resolved → the bytes are stored. Processing continues in the
// background worker; `upload_id` is how the gallery follows it.
if (response.data?.upload_id) {
uploadIds.push(response.data.upload_id);
}
setProcessingFiles(prev => {
const next = { ...prev };
delete next[file.name];
@@ -190,7 +199,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
if (successCount > 0) {
toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`);
onUploadComplete(successCount);
onUploadComplete(uploadIds);
}
if (failedCount > 0) {
@@ -1,5 +1,6 @@
/**
* A guest upload must show up in the grid on its own.
* A guest upload must show up in the grid on its own — and say so while it is
* still being worked on.
*
* Guest uploads are queued: `POST /gallery/:id/upload` answers 202 and the row
* lands as `processing_status: 'pending'`, while `GET /gallery/:slug/photos`
@@ -8,6 +9,10 @@
* the payload was still byte-identical, the browser was answered 304, and the
* guest's photo silently vanished until they hard-reloaded (QA P4-E.01).
*
* The follow-up (B7) replaced the blind count-baseline poll with one driven by
* the real processing status of the guest's own upload group, so the UI can
* show "processing…" and report a failure instead of timing out in silence.
*
* GalleryView needs its providers, the router and a dozen child components to
* render, so this pins the contract at source level (same approach as
* facePreviewRendition.test.ts).
@@ -16,42 +21,75 @@ import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const source = fs.readFileSync(
path.join(__dirname, '..', 'GalleryView.tsx'),
const read = (...parts: string[]) =>
fs.readFileSync(path.join(__dirname, '..', ...parts), 'utf8');
const source = read('GalleryView.tsx');
const uploadSource = read('UserPhotoUpload.tsx');
const serviceSource = fs.readFileSync(
path.join(__dirname, '..', '..', '..', 'services', 'gallery.service.ts'),
'utf8'
);
const handler = source.slice(
source.indexOf('const handleUploadComplete'),
source.indexOf('const uploadProcessingNotice')
);
describe('post-upload photo refresh', () => {
it('never reloads the page to pick up an upload', () => {
expect(source).not.toContain('window.location.reload');
});
it('keeps refetching until the queued photos appear', () => {
const handler = source.slice(
source.indexOf('const handleUploadComplete'),
source.indexOf('// Get individual protection settings')
);
expect(handler).toContain('await refetch()');
it('drives the refresh off the upload group\'s processing status', () => {
expect(handler).toContain('galleryService.getUploadStatus(slug, uploadIds)');
// Refetch as photos land, not only once the whole batch settles.
expect(handler).toContain('status.complete > lastComplete');
expect(handler).toMatch(/setInterval\(poll/);
// Bounded: stop once the new photos land, and stop regardless after the
// deadline so a failed background job can't leave a poll running forever.
//
// Waits for ALL queued files, not just the first. Each is processed
// independently, so a `> baseline` comparison stops at photo 1 of N and
// leaves the rest hidden until a manual refresh — the exact symptom this
// polling exists to prevent.
expect(handler).toContain('baseline + Math.max(1, queuedCount)');
expect(handler).toContain('>= target');
});
it('stops on the real terminal condition rather than a count baseline', () => {
expect(handler).toContain('status.pending === 0 && status.processing === 0');
// Still bounded, so a wedged worker can never leave a poll running forever.
expect(handler).toContain('Date.now() > deadline');
});
it('tells the guest when a photo failed processing or is still queued', () => {
expect(handler).toContain("toast.error(t('upload.processingFailed'");
expect(handler).toContain("toast.info(t('upload.processingStillRunning')");
// ...and renders a "processing…" notice while the poll runs.
expect(source).toContain("t('upload.processing')");
expect(source).toContain("t('upload.processingProgress'");
});
it('degrades to a plain refetch when the status call itself fails', () => {
expect(handler).toContain('} catch {');
expect(handler).toContain('await finish();');
});
it('wires the polling handler into the upload modals that render the grid', () => {
const wired = source.match(/onUploadComplete=\{handleUploadComplete\}/g) || [];
expect(wired.length).toBeGreaterThanOrEqual(2);
// The notice is rendered next to each of them; the two layout branches
// have no shared wrapper to hang it on.
const shown = source.match(/\{uploadProcessingNotice\}/g) || [];
expect(shown.length).toBe(wired.length);
});
it('clears the poll when the gallery unmounts', () => {
expect(source).toContain('useEffect(() => stopUploadRefresh, [])');
});
});
describe('upload id plumbing', () => {
it('hands the 202 upload ids to the gallery', () => {
expect(uploadSource).toContain('onUploadComplete: (uploadIds: string[]) => void');
expect(uploadSource).toContain('uploadIds.push(response.data.upload_id)');
expect(uploadSource).toContain('onUploadComplete(uploadIds)');
});
it('asks the gallery-scoped status route, batching the ids into one request', () => {
expect(serviceSource).toContain('`/gallery/${slug}/uploads/status`');
expect(serviceSource).toContain("params: { ids: uploadIds.join(',') }");
});
});
+21
View File
@@ -44,6 +44,15 @@ function isIOS(): boolean {
// the existing server-side zip flow.
const MAX_WEB_SHARE_FILES = 25;
// Counts-only snapshot returned by GET /gallery/:slug/uploads/status.
export interface UploadProcessingStatus {
total: number;
pending: number;
processing: number;
complete: number;
failed: number;
}
export const galleryService = {
// Verify share token
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
@@ -89,6 +98,18 @@ export const galleryService = {
};
},
// Processing status for the guest's own uploads (B7). A guest upload is
// queued — the route answers 202 and getGalleryPhotos only returns rows that
// finished processing — so this is what tells the gallery whether a photo
// that has not appeared yet is still in the worker's queue or failed
// outright. Scoped server-side to the gallery this token unlocked.
async getUploadStatus(slug: string, uploadIds: string[]): Promise<UploadProcessingStatus> {
const response = await api.get<UploadProcessingStatus>(`/gallery/${slug}/uploads/status`, {
params: { ids: uploadIds.join(',') },
});
return response.data;
},
// Save single photo. iOS routes through the Web Share API so the
// share sheet's "Save Image" action lands the file in Photos.
// Everywhere else (Android, desktop) navigates a hidden anchor