feat(downloads): per-gallery download resolutions (#858) (#1022)

Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.

STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.

RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.

Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.

Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.

Closes #858.
This commit is contained in:
Paul Nothaft
2026-08-11 09:46:46 +02:00
committed by GitHub
parent 02deac9f10
commit 8e3573788b
29 changed files with 2716 additions and 82 deletions
@@ -0,0 +1,140 @@
// Per-event download resolution overrides (#858). Same sub-router shape as
// ./slideshow.js — see ./index.js for the registration-order contract.
//
// All three fields are tri-state: explicit null = inherit the global
// (Settings → Downloads), matching show_watermark / show_qr. Changing the
// standard resolution invalidates this event's cached download-all zip,
// because that archive is built AT the standard resolution.
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const { errorResponse } = require('../../utils/routeHelpers');
const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const {
getDownloadGlobals,
resolveEventDownloadPolicy,
ORIGINAL,
} = require('../../utils/downloadResolutions');
const downloadZipService = require('../../services/downloadZipService');
async function loadOwnedEvent(req) {
let q = db('events').where('id', req.params.id);
if (req.admin.roleName === 'editor') {
q = q.where('created_by', req.admin.id);
}
return q.first();
}
module.exports = (router) => {
// Read the event's effective policy plus the raw overrides, so the admin UI
// can show "inheriting 1500x1000" vs "overridden to Original".
router.get('/:id/download-resolutions', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const event = await loadOwnedEvent(req);
if (!event) return res.status(404).json({ error: 'Event not found' });
const globals = await getDownloadGlobals();
const policy = await resolveEventDownloadPolicy(event);
res.json({
overrides: {
download_standard_resolution: event.download_standard_resolution ?? null,
download_resolution_picker_enabled: event.download_resolution_picker_enabled ?? null,
download_allow_original: event.download_allow_original ?? null,
},
globals,
effective: {
standard: policy.standard,
picker_enabled: policy.pickerEnabled,
allow_original: policy.allowOriginal,
choices: policy.choices,
},
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load download resolution settings');
}
});
router.patch('/:id/download-resolutions', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('download_standard_resolution').optional({ nullable: true }),
body('download_resolution_picker_enabled').optional({ nullable: true }),
body('download_allow_original').optional({ nullable: true }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Invalid download settings', details: errors.array() });
}
const event = await loadOwnedEvent(req);
if (!event) return res.status(404).json({ error: 'Event not found' });
const globals = await getDownloadGlobals();
const updates = {};
let standardChanged = false;
if (req.body.download_standard_resolution !== undefined) {
const raw = req.body.download_standard_resolution;
if (raw === null) {
updates.download_standard_resolution = null;
} else {
const v = String(raw);
if (v !== ORIGINAL && !globals.resolutions.some((r) => r.id === v)) {
return res.status(400).json({ error: `Unknown resolution "${v}"` });
}
updates.download_standard_resolution = v;
}
standardChanged = (event.download_standard_resolution ?? null)
!== (updates.download_standard_resolution ?? null);
}
// events has no updated_at column — don't set it.
if (req.body.download_resolution_picker_enabled !== undefined) {
updates.download_resolution_picker_enabled = req.body.download_resolution_picker_enabled === null
? null
: formatBoolean(parseBooleanInput(req.body.download_resolution_picker_enabled, false));
}
if (req.body.download_allow_original !== undefined) {
updates.download_allow_original = req.body.download_allow_original === null
? null
: formatBoolean(parseBooleanInput(req.body.download_allow_original, false));
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'No settings supplied' });
}
await db('events').where('id', event.id).update(updates);
// Only the standard resolution changes what the cached archive contains.
// Toggling the picker or the original allowance doesn't, so don't throw
// away a valid zip for those.
if (standardChanged) {
downloadZipService.invalidate(event.id);
}
const fresh = await db('events').where('id', event.id).first();
const policy = await resolveEventDownloadPolicy(fresh);
await logActivity('event_download_resolutions_updated', {
event_id: event.id, ...updates,
}, null, { type: 'admin', id: req.admin.id, name: req.admin.username });
res.json({
message: 'Download resolution settings updated',
zip_invalidated: standardChanged,
effective: {
standard: policy.standard,
picker_enabled: policy.pickerEnabled,
allow_original: policy.allowOriginal,
choices: policy.choices,
},
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to save download resolution settings');
}
});
};
+1
View File
@@ -10,6 +10,7 @@ const router = express.Router();
require('./crud')(router);
require('./slideshow')(router);
require('./downloadResolutions')(router);
require('./resets')(router);
require('./archiveBulk')(router);
require('./logo')(router);
+127 -1
View File
@@ -51,7 +51,13 @@ const RESERVED_SETTING_KEYS = [
// clobbered with plaintext, and the policy/mapping keys carry invariants
// (role targets exist, break-glass account present) that only the dedicated
// PUT /sso validates — a generic upsert would bypass all of them.
const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key) || key.startsWith('oidc_');
// Every download_* key is reserved too (#858): the cached download-all zip is
// built AT the standard resolution, so changing it has to invalidate those
// zips and re-validate the value against the preset list. A generic upsert
// would do neither, leaving galleries handing out archives at the old size.
const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key)
|| key.startsWith('oidc_')
|| key.startsWith('download_');
const stripReservedSettingKeys = (settings) => {
for (const key of Object.keys(settings)) {
if (isReservedSettingKey(key)) delete settings[key];
@@ -443,6 +449,126 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
}
});
// ──────────────────────────────────────────────────────────────────────────
// Download resolutions (#858). The standard resolution is what every ordinary
// download hands out; the picker is an opt-in modal letting guests choose a
// different size. Dedicated endpoints because a change here has to invalidate
// the pre-built download-all zips, which are built AT the standard resolution.
// ──────────────────────────────────────────────────────────────────────────
router.get('/downloads', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { getDownloadGlobals } = require('../utils/downloadResolutions');
res.json(await getDownloadGlobals());
} catch (error) {
errorResponse(res, error, 500, 'Failed to load download settings');
}
});
router.put('/downloads', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const {
invalidateDownloadGlobals, getDownloadGlobals, ORIGINAL,
} = require('../utils/downloadResolutions');
const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k);
const updates = [];
const push = (key, value) => updates.push({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'download',
});
// Presets first — the standard is validated against the resulting list,
// so a single request can add a size and select it in one go.
const before = await getDownloadGlobals();
const previousStandard = before.standard_resolution;
let presets = before.resolutions;
if (has('download_resolutions')) {
const raw = Array.isArray(req.body.download_resolutions) ? req.body.download_resolutions : [];
const cleaned = [];
const seen = new Set();
for (const p of raw) {
const width = Math.round(Number(p?.width));
const height = Math.round(Number(p?.height));
// 20000px ceiling keeps a typo ("30000000") from asking sharp for a
// multi-terabyte canvas on every subsequent download.
if (!width || !height || width < 1 || height < 1 || width > 20000 || height > 20000) continue;
const id = `${width}x${height}`;
if (seen.has(id)) continue;
seen.add(id);
cleaned.push({ label: String(p.label || id).slice(0, 40), width, height });
}
if (cleaned.length === 0) {
return res.status(400).json({ error: 'At least one valid resolution is required' });
}
push('download_resolutions', cleaned);
presets = cleaned.map((p) => ({ ...p, id: `${p.width}x${p.height}` }));
}
if (has('download_standard_resolution')) {
const v = String(req.body.download_standard_resolution || ORIGINAL);
if (v !== ORIGINAL && !presets.some((p) => p.id === v)) {
return res.status(400).json({ error: `Unknown resolution "${v}"` });
}
push('download_standard_resolution', v);
} else if (has('download_resolutions')) {
// Replacing the preset list without naming a standard can orphan the
// CURRENT standard — galleries would keep handing out a size the picker
// no longer offers, breaking the "standard is always a preset" invariant.
if (previousStandard !== ORIGINAL && !presets.some((p) => p.id === previousStandard)) {
return res.status(400).json({
error: `The current standard resolution "${previousStandard}" is not in the new list — set download_standard_resolution in the same request`,
});
}
}
if (has('download_resolution_picker_enabled')) {
push('download_resolution_picker_enabled', !!req.body.download_resolution_picker_enabled);
}
if (has('download_allow_original')) {
push('download_allow_original', !!req.body.download_allow_original);
}
for (const u of updates) {
await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type);
}
invalidateDownloadGlobals();
// The cached download-all zip is built at the standard resolution, so a
// change to the GLOBAL standard makes every INHERITING gallery's zip
// stale. Events with their own override are unaffected and keep theirs.
// Only a REAL change to the standard invalidates. The settings form
// submits every field on every save, so keying off "was it present" would
// schedule a rebuild of every inheriting gallery each time an admin
// renamed a preset — a stampede on installs with many galleries.
const standardUpdate = updates.find((u) => u.setting_key === 'download_standard_resolution');
const standardChanged = standardUpdate
&& JSON.parse(standardUpdate.setting_value) !== previousStandard;
let invalidatedZips = 0;
if (standardChanged) {
// Every inheriting event, whether or not it currently HAS a cached zip:
// one may be mid-build against the old standard right now. Going through
// downloadZipService.invalidate bumps its generation counter, which
// aborts that build — a raw UPDATE would let it finish and re-publish a
// permanently stale archive.
const downloadZipService = require('../services/downloadZipService');
const inheriting = await db('events')
.whereNull('download_standard_resolution')
.select('id');
for (const ev of inheriting) {
downloadZipService.invalidate(ev.id);
}
invalidatedZips = inheriting.length;
}
res.json({
message: 'Download settings updated',
updated: updates.map((u) => u.setting_key),
invalidated_zips: invalidatedZips,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to save download settings');
}
});
// Get settings by type
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
+247 -32
View File
@@ -35,8 +35,17 @@ const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
const { toIso } = require('../utils/dateNormalize');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy, resizeToBox } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { renderPhotoForDownload } = require('../services/downloadRendition');
const downloadJobService = require('../services/downloadJobService');
// Download resolutions (#858) — the standard size a gallery hands out, plus
// validation of any guest-picked override.
const {
resolveEventDownloadPolicy,
pickRequestedResolution,
parseResolution,
} = require('../utils/downloadResolutions');
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const {
getUseOriginalFilenames,
@@ -885,6 +894,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
const useOriginalFilenames = await getUseOriginalFilenames();
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
const downloadPolicy = await resolveEventDownloadPolicy(req.event);
res.json({
event: {
@@ -898,6 +908,14 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
// Download resolutions (#858). `choices` drives the picker modal and is
// empty when the picker is off, so the UI can never offer a size the
// server would reject.
download_resolution: {
standard: downloadPolicy.standard,
picker_enabled: downloadPolicy.pickerEnabled,
choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [],
},
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
// polling so a re-hide propagates without a manual reload.
reveal_armed: req.event.reveal_mode === true || req.event.reveal_mode === 1 || req.event.reveal_mode === '1',
@@ -1121,6 +1139,18 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
}
}
// Download resolution (#858). Resolved BEFORE the counters below: a
// rejected resolution must not inflate download stats, which a guest
// could otherwise do by replaying ?resolution=bogus.
const isVideo = photo.media_type === 'video'
|| (photo.mime_type && photo.mime_type.startsWith('video/'));
const policy = await resolveEventDownloadPolicy(req.event);
const requested = pickRequestedResolution(policy, req.query.resolution);
if (requested === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const box = isVideo ? null : parseResolution(requested);
// Admin preview (#868) downloads are excluded from the download count +
// guest analytics — kept out of client-facing stats.
if (!req.isAdminPreview) {
@@ -1169,23 +1199,42 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
if (shouldApplyWatermark) {
// Apply watermark and send
// Use event watermark text if available, otherwise fall back to global settings
const effectiveSettings = {
// The gallery's standard applies to EVERY ordinary download, single photos
// included — otherwise a lowered standard is trivially bypassed by
// downloading photos one at a time. `box` was resolved above, before the
// counters. Videos have no resize path and always ship as-is.
if (shouldApplyWatermark || box) {
// Resize BEFORE watermarking: applyWatermark sizes the mark relative to
// its input's width, so watermarking the original and then shrinking
// would resample the mark and waste work on discarded pixels.
//
// With no resize (the default 'original' standard) hand applyWatermark
// the PATH, not a buffer: buffer inputs deliberately skip its cache, so
// buffering here would re-run sharp over the full-size original on every
// download and regress the pre-#858 watermark performance.
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
};
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
} : null;
let buffer;
if (!box) {
buffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
} else {
buffer = await resizeToBox(await fs.promises.readFile(filePath), box);
if (shouldApplyWatermark) {
buffer = await watermarkService.applyWatermark(buffer, effectiveSettings);
}
}
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': contentDisposition,
'Content-Length': watermarkedBuffer.length
'Content-Length': buffer.length
});
res.send(watermarkedBuffer);
res.send(buffer);
} else {
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
@@ -1368,6 +1417,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// The gallery's standard resolution applies to the streamed archive too,
// not only the cached one (#858).
const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event);
// Add photos to archive — managed photos via storage backend, external via local path.
const { resolvePhotoStorageKey } = require('../services/photoResolver');
const storage = getStorage();
@@ -1408,21 +1461,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
// Watermark service operates on a local path. For managed photos in
// S3 mode, materialize a tmp local copy first.
const { withLocalCopy } = require('../services/imageProcessor');
const sourceForWatermark = storageKey
? null
: resolvePhotoFilePath(req.event, photo);
const watermarkedBuffer = storageKey
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, effectiveSettings)
)
: await watermarkService.applyWatermark(sourceForWatermark, effectiveSettings);
archive.append(watermarkedBuffer, { name: archiveName });
// Resize to the gallery's standard resolution (#858) and/or watermark.
// This branch runs whenever the cached zip isn't usable — the first
// download after an invalidation, PIN clients, and galleries with
// hidden photos all land here, so skipping the cap would leak
// full-resolution files for exactly those cases.
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name: archiveName });
} else if (storageKey) {
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
@@ -1515,6 +1561,15 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
return res.status(404).json({ error: 'No photos found for selected IDs' });
}
// Download resolution (#858). Resolve BEFORE any header goes out — once
// the archive starts streaming we can no longer return a JSON error.
const selectedPolicy = await resolveEventDownloadPolicy(req.event);
const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution);
if (selectedResolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
const selectedBox = parseResolution(selectedResolution);
const archiveName = `${req.event.slug}-selected.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
@@ -1545,7 +1600,6 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
} : null;
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
const selectedStorage = getStorage();
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
@@ -1570,13 +1624,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
const buf = storageKey
? await withSelectedLocalCopy(storageKey, (lp) =>
watermarkService.applyWatermark(lp, effectiveSettings)
)
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), effectiveSettings);
archive.append(buf, { name });
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
// when neither applies, so the untransformed case still streams from
// storage rather than buffering the whole photo.
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
if (rendered) {
archive.append(rendered, { name });
} else if (storageKey) {
const stream = await selectedStorage.get(storageKey);
archive.append(stream, { name });
@@ -1623,6 +1676,168 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
});
// ──────────────────────────────────────────────────────────────────────────
// Custom-resolution download jobs (#858).
//
// The plain download-all is served from the pre-built cache at the gallery's
// STANDARD resolution. Picking a different size has nothing to cache against,
// and resizing a whole gallery inside one request would sit far past any
// reverse-proxy timeout — so those archives are built as a job the client
// polls. Same access rules as the download routes above.
// ──────────────────────────────────────────────────────────────────────────
// Kick off (or join) a build. Returns the polling token.
router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const policy = await resolveEventDownloadPolicy(req.event);
if (!policy.pickerEnabled) {
return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' });
}
const resolution = pickRequestedResolution(policy, req.body?.resolution);
if (resolution === null) {
return res.status(400).json({ error: 'Resolution not available for this gallery' });
}
// Optional subset. Absent = the whole visible gallery.
let photoIds = null;
if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) {
photoIds = req.body.photo_ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
}
let job;
try {
job = await downloadJobService.createJob({
event: req.event,
resolution,
photoIds,
accessLevel: req.accessLevel,
});
} catch (err) {
if (err.code === 'NO_PHOTOS') {
return res.status(404).json({ error: 'No photos available for this selection' });
}
if (err.code === 'BUSY') {
return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' });
}
throw err;
}
res.status(202).json({
token: job.token,
status: job.status,
resolution: job.resolution,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to start download preparation');
}
});
// Poll. The token is unguessable, but it is never sufficient on its own —
// verifyGalleryAccess still runs and the job must belong to THIS event.
router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
res.json({
status: job.status,
resolution: job.resolution,
photo_count: job.photo_count || 0,
size_bytes: job.size_bytes || null,
error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to read download job');
}
});
// Deliver the finished archive.
router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
// Downloads can be switched off after a job was created — every other
// download route re-checks this per request, so this one must too.
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const job = await downloadJobService.getStatus(req.params.token);
if (!job || job.event_id !== req.event.id) {
return res.status(404).json({ error: 'Download job not found' });
}
// The token alone never grants access: the archive was built under one
// visibility scope, and only a requester still in that scope may take it.
// Without this, a leaked client token would hand hidden photos to a guest.
if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) {
return res.status(404).json({ error: 'Download job not found' });
}
if (job.status !== 'ready' || !job.zip_path) {
return res.status(409).json({ error: 'Download is not ready yet', status: job.status });
}
if (new Date(job.expires_at).getTime() <= Date.now()) {
return res.status(410).json({ error: 'This download has expired — please request it again' });
}
// A photo hidden AFTER this archive was built is still inside it, and the
// scope check above can't see that — both sides remain 'public'. Re-run
// the visibility query over the packaged set before handing it over.
if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) {
return res.status(409).json({
error: 'This gallery changed since the download was prepared — please request it again',
status: 'stale',
});
}
const storage = getStorage();
const stat = await storage.stat(job.zip_path);
if (!stat) {
return res.status(410).json({ error: 'This download is no longer available' });
}
// Stats parity with the other bulk paths (#895): only count once the
// response actually completed, and keep admin previews out of guest stats.
res.on('finish', () => {
if (res.statusCode >= 400 || req.isAdminPreview) return;
// The DELIVERED set, not the requested one: a photo whose source was
// missing at build time isn't in the zip and must not be counted.
let ids = [];
try {
ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]');
} catch (_) { /* malformed row — skip counting rather than fail */ }
if (ids.length > 0) {
db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {});
}
db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download',
photo_id: null,
}).catch(() => {});
logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution },
req.event.id, galleryActor(req));
});
const suffix = job.resolution === 'original' ? 'original' : job.resolution;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', stat.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`);
const stream = await storage.get(job.zip_path);
stream.pipe(res);
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve prepared download');
}
});
// Explicit per-photo view beacon (#895). Counting views on the image-
// serving routes is wrong in both directions: the lightbox preloads the
// prev/next neighbours (three fetches per open), while a preloaded