diff --git a/backend/__tests__/integration/downloadResolutions.test.js b/backend/__tests__/integration/downloadResolutions.test.js new file mode 100644 index 00000000..45b23920 --- /dev/null +++ b/backend/__tests__/integration/downloadResolutions.test.js @@ -0,0 +1,256 @@ +/** + * Download resolutions (#858). + * + * Pins the contracts that are easy to break later: + * + * - the global → per-event cascade, including NULL = inherit + * - the picker never offers a size ABOVE the standard (a photographer who + * lowers the standard is not silently handing out full-res), and 'Original' + * only reappears when the admin explicitly allows it + * - `fit: 'inside'` + no-upscaling resize semantics, which is exactly what + * the requester asked for on the issue + * - a guest-supplied resolution is validated against the policy rather than + * trusted + */ + +const sharp = require('sharp'); + +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// Both modules under test pull in src/database/db.js transitively. bootCrmDb +// only works when it runs BEFORE the first require of db.js (it sets +// TEST_DATABASE_PATH, which knexfile reads at module-init time), so these are +// required lazily in beforeAll rather than at module scope — otherwise knex +// binds to the shared default SQLite file and every run after the first one +// fails with "table `migrations` already exists". +let resolveEventDownloadPolicy; +let pickRequestedResolution; +let parseResolution; +let invalidateDownloadGlobals; +let resizeToBox; + +describe('Download resolutions (#858)', () => { + let db; + let cleanup; + + const setGlobal = async (key, value) => { + await db('app_settings').where({ setting_key: key }).del(); + await db('app_settings').insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'download', + updated_at: new Date().toISOString(), + }); + invalidateDownloadGlobals(); + }; + + const PRESETS = [ + { label: 'Large', width: 3000, height: 2000 }, + { label: 'Medium', width: 1500, height: 1000 }, + { label: 'Small', width: 800, height: 600 }, + ]; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + ({ + resolveEventDownloadPolicy, + pickRequestedResolution, + parseResolution, + invalidateDownloadGlobals, + } = require('../../src/utils/downloadResolutions')); + ({ resizeToBox } = require('../../src/services/imageProcessor')); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + beforeEach(async () => { + await setGlobal('download_resolutions', PRESETS); + await setGlobal('download_standard_resolution', 'original'); + await setGlobal('download_resolution_picker_enabled', false); + await setGlobal('download_allow_original', false); + }); + + describe('cascade', () => { + it('inherits the global standard when the event has no override', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + const policy = await resolveEventDownloadPolicy({ download_standard_resolution: null }); + expect(policy.standard).toBe('1500x1000'); + expect(policy.standardBox).toEqual({ width: 1500, height: 1000 }); + }); + + it('lets an event override the global standard', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + const policy = await resolveEventDownloadPolicy({ download_standard_resolution: '800x600' }); + expect(policy.standard).toBe('800x600'); + }); + + it('treats a NULL picker flag as inherit and an explicit false as override', async () => { + await setGlobal('download_resolution_picker_enabled', true); + expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: null })).pickerEnabled).toBe(true); + expect((await resolveEventDownloadPolicy({ download_resolution_picker_enabled: false })).pickerEnabled).toBe(false); + }); + }); + + describe('choice list', () => { + it('never offers a size larger than the standard', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + const { choices } = await resolveEventDownloadPolicy({}); + expect(choices.map((c) => c.id)).toEqual(['1500x1000', '800x600']); + // The regression that matters: 3000x2000 must not be reachable. + expect(choices.some((c) => c.id === '3000x2000')).toBe(false); + }); + + it('bounds EACH dimension, not the pixel area (codex review round 2)', async () => { + // 2000x700 is 1.4MP — under 1500x1000's 1.5MP — so an area comparison + // would offer it and hand back a 2000px-wide file despite a 1500px cap. + await setGlobal('download_resolutions', [ + ...PRESETS, + { label: 'Wide', width: 2000, height: 700 }, + ]); + await setGlobal('download_standard_resolution', '1500x1000'); + const { choices } = await resolveEventDownloadPolicy({}); + expect(choices.some((c) => c.id === '2000x700')).toBe(false); + }); + + it('omits Original when the standard is capped and the admin has not allowed it', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + const { choices } = await resolveEventDownloadPolicy({}); + expect(choices.some((c) => c.id === 'original')).toBe(false); + }); + + it('re-adds Original when the admin explicitly allows it', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + await setGlobal('download_allow_original', true); + const { choices } = await resolveEventDownloadPolicy({}); + expect(choices[0].id).toBe('original'); + }); + + it('offers Original when the standard already is original', async () => { + const { choices } = await resolveEventDownloadPolicy({}); + expect(choices[0].id).toBe('original'); + expect(choices.map((c) => c.id)).toContain('3000x2000'); + }); + }); + + describe('request validation', () => { + it('falls back to the standard when nothing is requested', async () => { + await setGlobal('download_standard_resolution', '1500x1000'); + const policy = await resolveEventDownloadPolicy({}); + expect(pickRequestedResolution(policy, undefined)).toBe('1500x1000'); + }); + + it('refuses any explicit request while the picker is off', async () => { + const policy = await resolveEventDownloadPolicy({}); + expect(policy.pickerEnabled).toBe(false); + expect(pickRequestedResolution(policy, '800x600')).toBeNull(); + }); + + it('refuses a size that is not on the offered list', async () => { + await setGlobal('download_resolution_picker_enabled', true); + await setGlobal('download_standard_resolution', '1500x1000'); + const policy = await resolveEventDownloadPolicy({}); + // Above the standard → not offered → rejected rather than silently served. + expect(pickRequestedResolution(policy, '3000x2000')).toBeNull(); + expect(pickRequestedResolution(policy, '9999x9999')).toBeNull(); + expect(pickRequestedResolution(policy, '800x600')).toBe('800x600'); + }); + + it('parses only well-formed resolution ids', () => { + expect(parseResolution('original')).toBeNull(); + expect(parseResolution(null)).toBeNull(); + expect(parseResolution('abc')).toBeNull(); + expect(parseResolution('0x0')).toBeNull(); + expect(parseResolution('1500x1000')).toEqual({ width: 1500, height: 1000 }); + }); + }); + + describe('job dedup identity (codex review round 1)', () => { + // The leak this pins: a PIN client's archive contains hidden photos. If the + // dedup key ignored the visibility scope, a guest asking for the same size + // would be handed the client's job token — and the delivery route only + // checked the event id. + let jobService; + + beforeAll(() => { + jobService = require('../../src/services/downloadJobService'); + }); + + it('separates client and guest archives of the same size and photo set', () => { + const guest = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public'); + const client = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'hidden'); + expect(guest).not.toBe(client); + }); + + it('keys on the RESOLVED photo set, so a stale archive is not reused', () => { + const before = jobService.dedupKey(1, '1500x1000', [1, 2, 3], false, 'public'); + const afterUpload = jobService.dedupKey(1, '1500x1000', [1, 2, 3, 4], false, 'public'); + const afterHide = jobService.dedupKey(1, '1500x1000', [1, 2], false, 'public'); + expect(new Set([before, afterUpload, afterHide]).size).toBe(3); + }); + + it('is order-independent for the same set', () => { + expect(jobService.dedupKey(1, 'original', [3, 1, 2], true, 'public')) + .toBe(jobService.dedupKey(1, 'original', [1, 2, 3], true, 'public')); + }); + + it('maps access levels onto the two visibility scopes', () => { + expect(jobService.visibilityScopeFor('client')).toBe('hidden'); + expect(jobService.visibilityScopeFor('guest')).toBe('public'); + expect(jobService.visibilityScopeFor(undefined)).toBe('public'); + }); + }); + + describe('resize semantics', () => { + const make = (w, h) => sharp({ + create: { width: w, height: h, channels: 3, background: { r: 10, g: 100, b: 200 } }, + }).jpeg().toBuffer(); + + const box = { width: 1500, height: 1000 }; + + it('fits a 3:2 photo exactly into a 3:2 box', async () => { + const out = await sharp(await resizeToBox(await make(6000, 4000), box)).metadata(); + expect([out.width, out.height]).toEqual([1500, 1000]); + }); + + it('treats the box as an "up to" bound for other aspect ratios', async () => { + // Portrait: height is the binding edge, width comes out smaller. + const portrait = await sharp(await resizeToBox(await make(4000, 6000), box)).metadata(); + expect(portrait.height).toBe(1000); + expect(portrait.width).toBeLessThan(1500); + + const fourThree = await sharp(await resizeToBox(await make(4000, 3000), box)).metadata(); + expect(fourThree.height).toBe(1000); + expect(fourThree.width).toBeLessThan(1500); + }); + + it('never upscales an image already smaller than the box', async () => { + const out = await sharp(await resizeToBox(await make(800, 600), box)).metadata(); + expect([out.width, out.height]).toEqual([800, 600]); + }); + + it('passes the buffer through untouched for the original size', async () => { + const src = await make(4000, 3000); + expect(await resizeToBox(src, null)).toBe(src); + }); + + it('keeps the source format so the filename and mime type stay honest', async () => { + // A .gif re-encoded as JPEG would ship mislabelled bytes, since the + // download routes keep the original filename and mime type. + const gif = await sharp({ + create: { width: 4000, height: 3000, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).gif().toBuffer(); + const out = await sharp(await resizeToBox(gif, box)).metadata(); + expect(out.format).toBe('gif'); + expect(out.width).toBe(1333); + }); + + it('returns the input rather than throwing on an undecodable source', async () => { + const junk = Buffer.from('not an image'); + expect(await resizeToBox(junk, box)).toBe(junk); + }); + }); +}); diff --git a/backend/migrations/core/173_add_download_resolutions.js b/backend/migrations/core/173_add_download_resolutions.js new file mode 100644 index 00000000..4ca3cf95 --- /dev/null +++ b/backend/migrations/core/173_add_download_resolutions.js @@ -0,0 +1,137 @@ +/** + * Migration 173: Download resolutions (#858). + * + * Two related capabilities: + * + * 1. STANDARD resolution — the size a gallery hands out for every ordinary + * download (single photo, selected, download-all). Global default in + * app_settings, overridable per event. 'original' keeps today's behaviour, + * so existing installs are unaffected until an admin changes it. + * + * 2. Resolution PICKER — an opt-in modal letting guests choose a different + * size. Off by default. Custom-resolution archives are never cached: they + * run through `download_jobs` (build → poll → download) so a large gallery + * doesn't hold an HTTP connection open for minutes. + * + * Per-event columns are NULLABLE on purpose: NULL = inherit the global, matching + * the tri-state `show_watermark` / `show_qr` convention. The cached download-all + * zip is built AT the standard resolution, so changing either the global or an + * event override has to invalidate `events.download_zip_path` — the settings + * write paths do that, not this migration. + */ + +const PRESET_DEFAULTS = [ + { label: 'Large', width: 3000, height: 2000 }, + { label: 'Medium', width: 1500, height: 1000 }, + { label: 'Small', width: 800, height: 600 }, +]; + +const GLOBAL_DEFAULTS = [ + // 'original' | 'x' matching one of download_resolutions. + ['download_standard_resolution', 'original'], + // Master switch for the guest-facing picker. + ['download_resolution_picker_enabled', false], + // Whether 'Original' appears in the picker. Only consulted when the picker + // is on — a photographer who lowers the standard usually does NOT want + // guests helping themselves to full-res. + ['download_allow_original', false], + ['download_resolutions', PRESET_DEFAULTS], +]; + +exports.up = async function (knex) { + if (await knex.schema.hasTable('events')) { + const cols = [ + ['download_standard_resolution', (t) => t.string('download_standard_resolution', 32)], + ['download_resolution_picker_enabled', (t) => t.boolean('download_resolution_picker_enabled')], + ['download_allow_original', (t) => t.boolean('download_allow_original')], + ]; + for (const [name, add] of cols) { + if (!(await knex.schema.hasColumn('events', name))) { + await knex.schema.alterTable('events', add); + } + } + } + + if (!(await knex.schema.hasTable('download_jobs'))) { + await knex.schema.createTable('download_jobs', (table) => { + table.increments('id').primary(); + // 64 hex chars = 32 bytes. Unguessable, but never sufficient on its own — + // the download route still runs the gallery access middleware and matches + // the job's event_id. + table.string('token', 64).notNullable().unique(); + table.integer('event_id').unsigned().notNullable() + .references('id').inTable('events').onDelete('CASCADE'); + // 'original' or 'x'. + table.string('resolution', 32).notNullable(); + // NULL = the whole visible gallery; otherwise the selected photo ids. + // Stored as JSON text so both SQLite and PG round-trip it identically. + table.text('photo_ids'); + // The subset that actually made it into the archive (a missing source is + // skipped). Drives download counts; photo_ids stays the REQUESTED set so + // the delivery fingerprint still matches. + table.text('delivered_photo_ids'); + // Stable hash of (resolution, visibility scope, resolved photo id set, + // watermark flag) — lets a second requester join an in-flight build + // instead of duplicating it. The scope is part of the hash so a client + // archive containing hidden photos can never be handed to a guest. + table.string('dedup_key', 64).notNullable(); + // 'public' | 'hidden' — recorded alongside the hash so delivery can + // re-check the requester still belongs to the scope the archive was + // built for. + table.string('visibility_scope', 16).notNullable().defaultTo('public'); + // pending | building | ready | failed + table.string('status', 16).notNullable().defaultTo('pending'); + table.string('zip_path', 512); + table.bigInteger('size_bytes'); + table.integer('photo_count'); + table.text('error'); + // Lease heartbeat: a live worker stamps this while building. Recovery + // only fails rows whose heartbeat has gone stale, so a rolling restart + // can't kill jobs another replica is still working on. + table.timestamp('heartbeat_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('completed_at'); + // Swept by downloadJobCleanupService once this passes. + table.timestamp('expires_at').notNullable(); + table.index(['event_id', 'dedup_key', 'status'], 'download_jobs_dedup_idx'); + table.index(['expires_at'], 'download_jobs_expiry_idx'); + }); + } + + if (!(await knex.schema.hasTable('app_settings'))) return; + for (const [key, value] of GLOBAL_DEFAULTS) { + const existing = await knex('app_settings').where('setting_key', key).first(); + if (!existing) { + await knex('app_settings').insert({ + setting_key: key, + // JSON-stringified so SQLite (TEXT) and Postgres (JSONB) both + // round-trip a recognisable shape — same as migration 104. + setting_value: JSON.stringify(value), + setting_type: 'download', + updated_at: new Date().toISOString(), + }); + } + } +}; + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('download_jobs'); + + if (await knex.schema.hasTable('app_settings')) { + await knex('app_settings') + .whereIn('setting_key', GLOBAL_DEFAULTS.map(([k]) => k)) + .del(); + } + + if (await knex.schema.hasTable('events')) { + for (const name of [ + 'download_standard_resolution', + 'download_resolution_picker_enabled', + 'download_allow_original', + ]) { + if (await knex.schema.hasColumn('events', name)) { + await knex.schema.alterTable('events', (table) => table.dropColumn(name)); + } + } + } +}; diff --git a/backend/server.js b/backend/server.js index d7695369..1b41b3ec 100644 --- a/backend/server.js +++ b/backend/server.js @@ -21,6 +21,7 @@ const { initializeDatabase, db } = require('./src/database/db'); const { startFileWatcher } = require('./src/services/fileWatcher'); const { startExpirationChecker } = require('./src/services/expirationChecker'); const { startTransferCleanup } = require('./src/services/transferCleanupService'); +const { startDownloadJobCleanup } = require('./src/services/downloadJobCleanupService'); const { startRevealScheduler } = require('./src/services/revealScheduler'); const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService'); const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor'); @@ -912,6 +913,9 @@ async function startServer() { // PicTransfer retention sweep (#997): expire links, notify admins, and // hard-delete client uploads once the grace window elapses. startTransferCleanup(); + // Custom-resolution download archives (#858) are disposable renditions — + // sweep them once their TTL passes so .download-cache doesn't grow forever. + startDownloadJobCleanup(); // Reveal-mode scheduler (#838): minutely stamp for scheduled reveals. startRevealScheduler(); // CRM invoice scheduler: hourly tick to flush scheduled-send invoices diff --git a/backend/src/routes/adminEvents/downloadResolutions.js b/backend/src/routes/adminEvents/downloadResolutions.js new file mode 100644 index 00000000..9ad25f91 --- /dev/null +++ b/backend/src/routes/adminEvents/downloadResolutions.js @@ -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'); + } + }); +}; diff --git a/backend/src/routes/adminEvents/index.js b/backend/src/routes/adminEvents/index.js index 7677a2cc..fa5774b1 100644 --- a/backend/src/routes/adminEvents/index.js +++ b/backend/src/routes/adminEvents/index.js @@ -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); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 5909d695..f13c5f68 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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 — diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 1396cf78..bef462c3 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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 diff --git a/backend/src/services/downloadJobCleanupService.js b/backend/src/services/downloadJobCleanupService.js new file mode 100644 index 00000000..65a1fbcb --- /dev/null +++ b/backend/src/services/downloadJobCleanupService.js @@ -0,0 +1,43 @@ +/** + * downloadJobCleanupService — TTL sweep for custom-resolution download jobs + * (#858). + * + * Job archives are one-off renditions of a gallery at a size nothing else + * caches against, so they are pure disposable bytes: once the TTL passes the + * row and its zip go away. Without this sweep, every guest who ever picked a + * non-standard resolution would leave a full gallery-sized archive behind in + * `.download-cache` forever. + * + * Runs every 20 minutes, offset from the hourly jobs so the three cleanup + * schedulers don't all wake at once. + */ + +const cron = require('node-cron'); +const logger = require('../utils/logger'); +const downloadJobService = require('./downloadJobService'); + +function startDownloadJobCleanup() { + // A restart leaves any in-flight build with no worker. Fail those rows once + // at startup so their owners get a clear error instead of polling forever. + downloadJobService.recoverOrphanedJobs().catch((err) => + logger.error('Download job recovery failed', { error: err.message })); + + cron.schedule('7,27,47 * * * *', async () => { + await runDownloadJobCleanup(); + }); + logger.info('Download job cleanup scheduler started'); +} + +async function runDownloadJobCleanup() { + try { + await downloadJobService.sweepExpired(); + } catch (err) { + logger.error('Download job cleanup failed', { error: err.message }); + } +} + +module.exports = { + startDownloadJobCleanup, + // exported for tests / manual invocation + runDownloadJobCleanup, +}; diff --git a/backend/src/services/downloadJobService.js b/backend/src/services/downloadJobService.js new file mode 100644 index 00000000..87a94e30 --- /dev/null +++ b/backend/src/services/downloadJobService.js @@ -0,0 +1,418 @@ +/** + * Custom-resolution download jobs (#858). + * + * The plain download-all is served from the pre-built cache, which is built at + * the gallery's STANDARD resolution. When a guest picks a different size there + * is nothing to cache against — so instead of holding an HTTP connection open + * while sharp chews through a whole gallery (a reverse proxy would time it out + * long before it finished), the archive is built as a job: + * + * POST .../download-jobs → { token, status: 'pending' } + * GET .../download-jobs/:token → poll { status, progress } + * GET .../download-jobs/:token/file → the finished zip + * + * State lives in the `download_jobs` table rather than in memory: an in-memory + * map loses every "ready" job on restart and is simply wrong the moment the + * backend runs more than one replica. + * + * Artifacts land in the same `.download-cache` directory as the pre-built zip. + * That directory is a dotfile, and s3AutoImporter skips dotfiles, so job zips + * can never be mistaken for gallery photos and re-imported. + */ + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const fsp = require('fs').promises; +const crypto = require('crypto'); +const archiver = require('archiver'); +const { db } = require('../database/db'); +const { getStorage } = require('./storage'); +const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService'); +const { renderPhotoForDownload, resolveWatermarkSettings } = require('./downloadRendition'); +const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); +const { parseResolution } = require('../utils/downloadResolutions'); +const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility'); +const logger = require('../utils/logger'); + +// How long a finished archive stays downloadable before the sweep deletes it. +const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour +// Guards against a handful of guests each kicking off a whole-gallery resize. +const MAX_CONCURRENT_BUILDS = 2; +// Hard ceiling on queued+running builds. Gallery routes are not behind the +// general rate limiter, so without this a token holder could vary the photo +// subset to enqueue unbounded 500-photo resizes — each one parking a promise +// and eventually a full gallery's worth of CPU and disk. +const MAX_QUEUED_BUILDS = 8; +// A build heartbeats while it works. A row whose heartbeat is older than this +// has no live worker (crashed or restarted) and may be failed by recovery. +const LEASE_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Collapse an access level to the visibility scope that decides WHICH photos a + * requester may receive. Anything that can see hidden photos is one scope; + * ordinary guests are another. Part of the job dedup identity so archives are + * never shared across the boundary. + */ +function visibilityScope(accessLevel) { + return canSeeHiddenPhotos(accessLevel) ? 'hidden' : 'public'; +} + +class DownloadJobService { + constructor() { + this.running = 0; + // jobId -> in-flight build promise. Only jobs present here are safe to + // rejoin; rows left 'pending'/'building' by a previous process are not. + this.liveBuilds = new Map(); + // Slots claimed between the admission check and the liveBuilds entry. + // Without it, concurrent requests all pass the check before any registers. + this.reserved = 0; + } + + cacheDir(slug) { + return path.posix.join('events/active', slug, '.download-cache'); + } + + jobKey(slug, token) { + return path.posix.join(this.cacheDir(slug), `job-${token}.zip`); + } + + /** + * Stable identity for "the same archive". + * + * SECURITY: `visibilityScope` and the RESOLVED photo id list are part of the + * identity, not just the requested one. A PIN client sees hidden photos that + * an ordinary guest must not; without the scope in the key, a guest asking + * for the same size would be handed the client's job token and could + * download hidden photos, because the delivery route only checks the event + * id. Hashing the resolved id set also stops a stale archive being reused + * after photos are added, removed or hidden. + */ + dedupKey(eventId, resolution, resolvedPhotoIds, watermark, visibilityScope) { + const ids = [...resolvedPhotoIds].map(Number).sort((a, b) => a - b).join(','); + // The full watermark SETTINGS, not just the on/off flag: an admin who + // edits the watermark text or logo while it stays enabled would otherwise + // have the old mark served from a ready job for the rest of its TTL. + const wm = watermark + ? crypto.createHash('sha256').update(JSON.stringify(watermark)).digest('hex').slice(0, 16) + : 'raw'; + return crypto.createHash('sha256') + .update(`${eventId}|${resolution}|${visibilityScope}|${ids}|${wm}`) + .digest('hex') + .slice(0, 64); + } + + /** + * The photos a given requester would actually receive. Used both to build + * the dedup identity and to build the archive, so the two can never drift. + */ + photoQuery(eventId, photoIds, accessLevel) { + let query = db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', eventId) + .where(function () { + this.whereNull('photos.category_id') + .orWhere('photo_categories.allow_downloads', true) + .orWhereNull('photo_categories.allow_downloads'); + }); + if (photoIds && photoIds.length) { + query = query.whereIn('photos.id', photoIds); + } + return applyPhotoVisibilityFilter(query, accessLevel); + } + + /** + * Find a job that already satisfies this request — either still building or + * finished and not yet expired. Failed jobs are ignored so a transient error + * doesn't poison every later attempt. + * + * `pending`/`building` rows are only reusable while THIS process is actually + * building them: after a restart those rows have no live worker, so rejoining + * one would leave the client polling until the TTL expires. + */ + async findReusable(eventId, dedupKey) { + const rows = await db('download_jobs') + .where({ event_id: eventId, dedup_key: dedupKey }) + .whereIn('status', ['pending', 'building', 'ready']) + .where('expires_at', '>', new Date().toISOString()) + .orderBy('id', 'desc'); + return rows.find((r) => r.status === 'ready' || this.liveBuilds.has(r.id)) || null; + } + + /** + * Create (or join) a job. Returns the job row. Building continues in the + * background — callers poll getStatus(). + */ + async createJob({ event, resolution, photoIds, accessLevel }) { + const watermark = await resolveWatermarkSettings(event); + + // Resolve the photo set up front, under THIS requester's visibility, so + // the dedup identity reflects what they may actually receive. + const resolved = await this.photoQuery(event.id, photoIds, accessLevel).select('photos.id'); + const resolvedIds = resolved.map((r) => r.id); + if (resolvedIds.length === 0) { + const err = new Error('No photos available for this selection'); + err.code = 'NO_PHOTOS'; + throw err; + } + + const scope = visibilityScope(accessLevel); + const dedupKey = this.dedupKey(event.id, resolution, resolvedIds, watermark, scope); + + const existing = await this.findReusable(event.id, dedupKey); + if (existing) return existing; + + // Refuse rather than queue without bound. The client shows this as a + // retryable error, which is far better than accepting work the box can't + // absorb and timing the user out anyway. + // + // The slot is reserved SYNCHRONOUSLY here — checking liveBuilds.size and + // only populating it after the awaited insert let a burst of concurrent + // requests all pass the check before any of them registered. + if (this.reserved + this.liveBuilds.size >= MAX_QUEUED_BUILDS) { + const err = new Error('Too many downloads are being prepared right now'); + err.code = 'BUSY'; + throw err; + } + this.reserved += 1; + + try { + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + JOB_TTL_MS).toISOString(); + + const inserted = await db('download_jobs').insert({ + token, + event_id: event.id, + resolution, + photo_ids: JSON.stringify(resolvedIds), + dedup_key: dedupKey, + visibility_scope: scope, + status: 'pending', + heartbeat_at: new Date().toISOString(), + created_at: new Date().toISOString(), + expires_at: expiresAt, + }).returning('id'); + const id = inserted[0]?.id ?? inserted[0]; + + // Fire and forget — the row is the source of truth for progress. The + // liveBuilds entry is what makes a pending/building row reusable; a row + // without one is an orphan from a previous process. + const promise = this._build(id, event, resolution, resolvedIds, watermark, accessLevel) + .catch((err) => logger.error('Download job build failed', { jobId: id, error: err.message })) + .finally(() => this.liveBuilds.delete(id)); + this.liveBuilds.set(id, promise); + + return await db('download_jobs').where({ id }).first(); + } finally { + // The slot is now accounted for by liveBuilds (or the insert failed). + this.reserved -= 1; + } + } + + async getStatus(token) { + return db('download_jobs').where({ token }).first(); + } + + /** Exposed so the delivery route can re-check the requester's scope. */ + visibilityScopeFor(accessLevel) { + return visibilityScope(accessLevel); + } + + /** + * Is every photo in this finished archive STILL visible to the requester? + * + * The scope check alone isn't enough: a photo hidden after a public job went + * ready stays inside that zip, and both job and requester are still + * 'public', so the archive would keep serving it for the rest of its TTL. + * Re-running the visibility query at delivery closes that window. + */ + async isStillDeliverable(job, event, accessLevel) { + let ids; + try { + ids = JSON.parse(job.photo_ids || '[]'); + } catch (_) { + return false; + } + if (!Array.isArray(ids) || ids.length === 0) return false; + + // Every packaged photo must still be visible to this requester. + const visible = await this.photoQuery(job.event_id, ids, accessLevel).select('photos.id'); + if (visible.length !== ids.length) return false; + + // …and the archive must still match the CURRENT rendition policy. Turning + // a watermark on, editing it, or revoking a resolution after the job went + // ready would otherwise keep serving the old bytes for the rest of the + // TTL. Recomputing the identity is the cheapest way to notice: any input + // that changes the archive changes the key. + const watermark = await resolveWatermarkSettings(event); + const expected = this.dedupKey( + job.event_id, job.resolution, ids, watermark, visibilityScope(accessLevel) + ); + return expected === job.dedup_key; + } + + async _fail(id, message) { + await db('download_jobs').where({ id }).update({ + status: 'failed', + error: String(message).slice(0, 500), + completed_at: new Date().toISOString(), + }); + } + + async _build(id, event, resolution, photoIds, watermarkSettings, accessLevel) { + // Back-pressure: a queued job stays 'pending' (which the UI shows as + // "preparing") rather than piling more sharp pipelines onto a box that is + // already saturated. + while (this.running >= MAX_CONCURRENT_BUILDS) { + await new Promise((r) => setTimeout(r, 500)); + } + this.running += 1; + + let tmpDir; + try { + await db('download_jobs').where({ id }).update({ status: 'building', heartbeat_at: new Date().toISOString() }); + + // Same query that produced the dedup identity, so the archive can never + // contain photos the requester wasn't entitled to at creation time. + const photos = await this.photoQuery(event.id, photoIds, accessLevel) + .select('photos.*') + .orderBy('photos.uploaded_at', 'desc'); + + if (photos.length === 0) { + await this._fail(id, 'No photos available for this selection'); + return; + } + + const box = parseResolution(resolution); + const storage = getStorage(); + const useOriginal = await getUseOriginalFilenames(); + const entryNames = getZipEntryNames(photos, useOriginal); + + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-dljob-')); + const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}.zip`); + + let appended = 0; + const appendedIds = []; + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(tmpPath); + // level 0 — photos are already compressed, so deflate only burns CPU. + const archive = archiver('zip', { zlib: { level: 0 } }); + output.on('close', resolve); + archive.on('error', reject); + archive.pipe(output); + + (async () => { + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; + const name = entryNames[i] || `photo-${photo.id}.jpg`; + try { + const rendered = await renderPhotoForDownload(event, photo, box, watermarkSettings); + if (rendered) { + archive.append(rendered, { name }); + } else { + const key = resolvePhotoStorageKey(event, photo); + if (key) { + archive.append(await storage.get(key), { name }); + } else { + archive.file(resolvePhotoFilePath(event, photo), { name }); + } + } + appended += 1; + appendedIds.push(photo.id); + // Progress is coarse (photo count, not bytes) but it is what the + // modal needs to show movement on a long build. + if (appended % 10 === 0 || appended === photos.length) { + // Doubles as the lease heartbeat — see LEASE_TIMEOUT_MS. + await db('download_jobs').where({ id }) + .update({ photo_count: appended, heartbeat_at: new Date().toISOString() }); + } + } catch (err) { + logger.warn('Skipping photo in download job', { jobId: id, photoId: photo.id, error: err.message }); + } + } + archive.finalize(); + })().catch(reject); + }); + + if (appended === 0) { + await this._fail(id, 'No photos could be packaged'); + return; + } + + const stat = await fsp.stat(tmpPath); + const key = this.jobKey(event.slug, (await db('download_jobs').where({ id }).first()).token); + await storage.putFromFile(key, tmpPath); + + await db('download_jobs').where({ id }).update({ + status: 'ready', + zip_path: key, + size_bytes: stat.size, + photo_count: appended, + // Only what actually landed in the zip: a missing/corrupt source is + // skipped, and counting it as downloaded would inflate that photo's + // stats for a file the guest never received. Kept SEPARATE from + // photo_ids, which is the requested set the dedup fingerprint was + // computed from — overwriting it would make every job with a skipped + // photo fail the delivery fingerprint check. + delivered_photo_ids: JSON.stringify(appendedIds), + completed_at: new Date().toISOString(), + }); + logger.info('Download job ready', { jobId: id, photos: appended, bytes: stat.size }); + } catch (err) { + logger.error('Download job error', { jobId: id, error: err.message }); + await this._fail(id, err.message).catch(() => {}); + } finally { + this.running -= 1; + if (tmpDir) await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + } + + /** + * Fail rows left mid-build by a previous process. Without this they linger + * as 'pending'/'building' until the TTL, and although findReusable now skips + * them, the client that owns such a token would poll a job that can never + * finish. Called once at startup. + */ + async recoverOrphanedJobs() { + // Only rows whose LEASE has expired. A live worker heartbeats while it + // builds, so in a multi-replica deployment a rolling restart can't have + // one replica fail jobs another is still working on — which a blanket + // "fail everything pending" would do. + const staleBefore = new Date(Date.now() - LEASE_TIMEOUT_MS).toISOString(); + const orphaned = await db('download_jobs') + .whereIn('status', ['pending', 'building']) + .where(function () { + this.whereNull('heartbeat_at').orWhere('heartbeat_at', '<', staleBefore); + }) + .update({ + status: 'failed', + error: 'Interrupted by a server restart — please request the download again', + completed_at: new Date().toISOString(), + }); + if (orphaned > 0) { + logger.info(`Failed ${orphaned} download job(s) orphaned by a restart`); + } + return orphaned; + } + + /** Delete expired jobs and their artifacts. Returns how many were removed. */ + async sweepExpired() { + const now = new Date().toISOString(); + const expired = await db('download_jobs').where('expires_at', '<=', now); + if (expired.length === 0) return 0; + + const storage = getStorage(); + for (const job of expired) { + if (job.zip_path) { + await storage.delete(job.zip_path).catch((e) => + logger.warn('Failed deleting download job artifact', { jobId: job.id, error: e.message })); + } + } + await db('download_jobs').whereIn('id', expired.map((j) => j.id)).del(); + logger.info(`Download job sweep removed ${expired.length} expired job(s)`); + return expired.length; + } +} + +module.exports = new DownloadJobService(); +module.exports.JOB_TTL_MS = JOB_TTL_MS; diff --git a/backend/src/services/downloadRendition.js b/backend/src/services/downloadRendition.js new file mode 100644 index 00000000..3c6c85a5 --- /dev/null +++ b/backend/src/services/downloadRendition.js @@ -0,0 +1,85 @@ +/** + * Download renditions (#858). + * + * One place that answers "what bytes does this photo ship as for this + * download?" — used by the single-photo route, the selected-photos archive, + * the cached download-all build, and the custom-resolution job builder. + * + * The ordering matters and is the whole reason this is centralised: the + * watermark is sized relative to its input's width, so it MUST be applied + * after the resize. Watermarking the original and then downscaling would + * resample the mark and burn CPU on pixels that get thrown away. + * + * Returns null when the photo needs no transformation at all, which lets + * callers stream the original straight from storage instead of buffering it. + */ + +const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); +const { withLocalCopy, resizeToBox } = require('./imageProcessor'); +const watermarkService = require('./watermarkService'); +const { getStorage } = require('./storage'); +const fs = require('fs'); + +/** Videos have no resize path — they always ship as stored. */ +function isVideo(photo) { + return photo.media_type === 'video' + || (photo.mime_type && String(photo.mime_type).startsWith('video/')); +} + +/** + * @param {object} event + * @param {object} photo + * @param {object?} box {width,height} or null for original size + * @param {object?} watermarkSettings effective settings, or null to skip + * @returns {Promise} null = serve the stored bytes unchanged + */ +async function renderPhotoForDownload(event, photo, box, watermarkSettings) { + const wantsResize = !!box && !isVideo(photo); + const wantsWatermark = !!(watermarkSettings && watermarkSettings.enabled); + if (!wantsResize && !wantsWatermark) return null; + + const storageKey = resolvePhotoStorageKey(event, photo); + + const transform = async (localPath) => { + // No resize → hand applyWatermark the PATH. Buffer inputs intentionally + // bypass its cache, so buffering here would re-run sharp over the + // full-size original for every download of an unresized gallery. + if (!wantsResize) { + return watermarkService.applyWatermark(localPath, watermarkSettings); + } + const buffer = await resizeToBox(await fs.promises.readFile(localPath), box); + return wantsWatermark + ? watermarkService.applyWatermark(buffer, watermarkSettings) + : buffer; + }; + + // Managed photos live behind the storage abstraction (possibly S3); external + // / reference photos are already on a local mount. + return storageKey + ? withLocalCopy(storageKey, transform) + : transform(resolvePhotoFilePath(event, photo)); +} + +/** + * Resolve the effective watermark settings for an event, or null when no + * watermark applies. Same global-OR-event rule the download routes already + * used, lifted here so the job builder can't drift from it. + */ +async function resolveWatermarkSettings(event) { + const settings = await watermarkService.getWatermarkSettings(); + const eventEnabled = event.watermark_downloads === true || event.watermark_downloads === 1; + const shouldApply = (settings && settings.enabled) || eventEnabled; + if (!shouldApply) return null; + return { + ...settings, + enabled: true, + text: event.watermark_text || settings?.text || 'Protected', + }; +} + +module.exports = { + renderPhotoForDownload, + resolveWatermarkSettings, + isVideo, + getStorage, +}; diff --git a/backend/src/services/downloadZipService.js b/backend/src/services/downloadZipService.js index 92503c8a..428c028b 100644 --- a/backend/src/services/downloadZipService.js +++ b/backend/src/services/downloadZipService.js @@ -24,6 +24,8 @@ const watermarkService = require('./watermarkService'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); const { getStorage } = require('./storage'); const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService'); +const { renderPhotoForDownload } = require('./downloadRendition'); +const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions'); const logger = require('../utils/logger'); const DEBOUNCE_MS = 5000; @@ -137,6 +139,12 @@ class DownloadZipService { text: event.watermark_text || watermarkSettings?.text || 'Protected', } : null; + // The cached archive is built AT the gallery's standard resolution + // (#858) — 'original' keeps the historical behaviour. Any change to the + // standard invalidates this zip via the settings write paths, so a + // cached archive always matches the currently configured size. + const { standardBox } = await resolveEventDownloadPolicy(event); + const finalKey = this.getCacheKey(event.slug); tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-')); @@ -182,26 +190,20 @@ class DownloadZipService { // for external, in which case fall back to resolvePhotoFilePath. const storageKey = resolvePhotoStorageKey(event, photo); - if (shouldApplyWatermark && effectiveSettings) { - try { - let sourcePath; - if (storageKey) { - // Stream the original to a tmp file just long enough for sharp - // (watermarkService) to operate on it. Avoids buffering the - // entire image in memory for huge originals. - sourcePath = path.join(tmpDir, `wm-${crypto.randomBytes(4).toString('hex')}`); - await storage.getToFile(storageKey, sourcePath); - } else { - sourcePath = resolvePhotoFilePath(event, photo); - } - const buf = await watermarkService.applyWatermark(sourcePath, effectiveSettings); - archive.append(buf, { name: archiveName }); - if (storageKey) { - await fsp.unlink(sourcePath).catch(() => {}); - } - } catch (err) { - logger.warn('Skipping watermark in pre-zip', { photoId: photo.id, error: err.message }); - } + // Resize to the gallery's standard resolution (#858) and/or + // watermark. Returns null when neither applies, so an + // original-size unwatermarked gallery still streams straight + // from storage with nothing buffered. + let rendered = null; + try { + rendered = await renderPhotoForDownload(event, photo, standardBox, effectiveSettings); + } catch (err) { + logger.warn('Skipping photo in pre-zip', { photoId: photo.id, error: err.message }); + continue; + } + + if (rendered) { + archive.append(rendered, { name: archiveName }); } else if (storageKey) { const stream = await storage.get(storageKey); archive.append(stream, { name: archiveName }); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index c307564e..e519678d 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -736,7 +736,75 @@ async function extractCaptureDate(imagePath) { } } +/** + * Downscale to fit inside a box, for the download-resolution feature (#858). + * + * `fit: 'inside'` + `withoutEnlargement` is exactly the "up to" semantic the + * requester asked for on #858: the box is a maximum, aspect ratio is kept + * (so a 3:2 box leaves a 4:3 photo slightly smaller than the box on one + * edge), and an image already smaller than the box is returned untouched + * rather than upscaled into mush. + * + * Takes and returns a Buffer so callers can chain resize → watermark without + * a tmp file. Returns the input unchanged when `box` is null ('original'). + * Never throws: on a corrupt/undecodable source it logs and returns the input, + * because failing a download outright is worse than serving the full size. + */ +async function resizeToBox(inputBuffer, box, options = {}) { + if (!box || !box.width || !box.height) return inputBuffer; + try { + const probe = sharp(inputBuffer, { limitInputPixels: 268402689, failOn: 'none' }); + const metadata = await probe.metadata(); + // Already inside the box — hand back the original bytes rather than + // re-encoding, which would only cost quality and CPU. + if (metadata.width && metadata.height + && metadata.width <= box.width && metadata.height <= box.height) { + return inputBuffer; + } + + const format = (metadata.format || '').toLowerCase(); + // Animated sources must be re-opened with `animated: true`, otherwise + // sharp keeps only the first frame and the download silently loses its + // animation. `.rotate()` would flatten an animated source, so it is + // applied only to stills (where EXIF orientation actually exists). + const animated = (metadata.pages || 1) > 1; + const image = animated + ? sharp(inputBuffer, { limitInputPixels: 268402689, failOn: 'none', animated: true }) + : probe.rotate(); + + let pipeline = image + .resize(box.width, box.height, { fit: 'inside', withoutEnlargement: true }); + + // Re-encode in the SOURCE format. The download routes keep the original + // filename and mime type, so emitting JPEG for a .gif would ship + // mislabelled bytes. GIF is an accepted upload format (multerConfig.photos). + // + // HEIC/HEIF is the exception: sharp builds generally cannot ENCODE it, and + // the browser can't display the original anyway (see originalNeedsPreview + // in gallery.js). Rather than emit JPEG bytes under a .heic name, leave + // those downloads at original size — correct-but-larger beats + // mislabelled-and-broken. + if (format === 'heif' || format === 'heic') { + return inputBuffer; + } + if (format === 'png') { + pipeline = pipeline.png({ compressionLevel: 6 }); + } else if (format === 'webp') { + pipeline = pipeline.webp({ quality: options.quality || 90 }); + } else if (format === 'gif') { + pipeline = pipeline.gif(); + } else { + pipeline = pipeline.jpeg({ quality: options.quality || 90, mozjpeg: true }); + } + return await pipeline.toBuffer(); + } catch (e) { + logger.warn(`resizeToBox failed (${box.width}x${box.height}), serving original: ${e.message}`); + return inputBuffer; + } +} + module.exports = { + resizeToBox, generateThumbnail, isThumbnailValid, ensureThumbnail, diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js index ccb9c6ee..9f37a8fc 100644 --- a/backend/src/services/watermarkService.js +++ b/backend/src/services/watermarkService.js @@ -90,18 +90,29 @@ class WatermarkService { /** * Apply watermark to an image */ + /** + * `imagePath` may be a path OR an in-memory Buffer (#858). Buffers let the + * download paths resize first and watermark second without a second tmp + * file — which matters because the mark is sized relative to the input's + * own width, so it has to be applied at the OUTPUT size to come out right. + */ async applyWatermark(imagePath, settings) { + const isBuffer = Buffer.isBuffer(imagePath); try { if (!settings || !settings.enabled) { // Return original image if watermarking is disabled - return await fs.readFile(imagePath); + return isBuffer ? imagePath : await fs.readFile(imagePath); } - // Check cache first - const cacheKey = `${imagePath}_${JSON.stringify(settings)}`; - const cached = this.cache.get(cacheKey); - if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) { - return cached.buffer; + // Check cache first. Buffer inputs are already-resized intermediates: + // they have no stable key (hashing megabytes per photo would cost more + // than the watermark) and no reuse across requests, so skip the cache. + const cacheKey = isBuffer ? null : `${imagePath}_${JSON.stringify(settings)}`; + if (cacheKey) { + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) { + return cached.buffer; + } } // Load the main image @@ -199,20 +210,24 @@ class WatermarkService { watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer(); } - // Cache the result - this.cache.set(cacheKey, { - buffer: watermarkedBuffer, - timestamp: Date.now() - }); + // Cache the result (path inputs only — see cacheKey above) + if (cacheKey) { + this.cache.set(cacheKey, { + buffer: watermarkedBuffer, + timestamp: Date.now() + }); - // Clean old cache entries - this.cleanCache(); + // Clean old cache entries + this.cleanCache(); + } return watermarkedBuffer; } catch (error) { logger.error('Error applying watermark:', error); - // Return original image on error - return await fs.readFile(imagePath); + // Return the un-watermarked input on error. Buffer inputs are already + // in memory — readFile() would treat the Buffer as a path and throw, + // turning a cosmetic watermark failure into a failed download. + return isBuffer ? imagePath : await fs.readFile(imagePath); } } diff --git a/backend/src/utils/downloadResolutions.js b/backend/src/utils/downloadResolutions.js new file mode 100644 index 00000000..fc754e25 --- /dev/null +++ b/backend/src/utils/downloadResolutions.js @@ -0,0 +1,176 @@ +/** + * Download resolutions (#858) — global defaults + per-event cascade. + * + * Two things live here: + * + * getDownloadGlobals() Cached read of the download_* app_settings. + * resolveEventDownloadPolicy() Folds an event row over those globals into + * the policy the download routes actually act + * on: which size is handed out by default, and + * which sizes (if any) a guest may pick from. + * + * Cascade rule: the per-event columns are NULLABLE and NULL means inherit, + * matching the tri-state `show_watermark` / `show_qr` convention. Same TTL + + * invalidate-on-write shape as slideshowGlobals — the gallery photo list and + * every download hit this, so it must not fan out into N settings reads. + */ + +const { getAppSetting } = require('./appSettings'); + +const TTL_MS = 5000; +let cache = null; // { at, val } + +const ORIGINAL = 'original'; + +// Mirrors migration 173's seed. Used when the setting row is missing entirely +// (fresh install mid-migration, or an admin who deleted the row). +const FALLBACK_PRESETS = [ + { label: 'Large', width: 3000, height: 2000 }, + { label: 'Medium', width: 1500, height: 1000 }, + { label: 'Small', width: 800, height: 600 }, +]; + +/** `{width, height}` → the canonical `'3000x2000'` id used on the wire. */ +function resolutionId(preset) { + return `${preset.width}x${preset.height}`; +} + +/** + * Parse a resolution id back into dimensions. Returns null for 'original', + * anything malformed, or non-positive/absurd values — callers treat null as + * "serve the original bytes", which is the safe direction: a bad id can only + * ever cost fidelity, never leak a larger image than intended. + */ +function parseResolution(id) { + if (!id || id === ORIGINAL) return null; + const m = /^(\d{1,5})x(\d{1,5})$/.exec(String(id)); + if (!m) return null; + const width = parseInt(m[1], 10); + const height = parseInt(m[2], 10); + if (!width || !height) return null; + return { width, height }; +} + +function normalisePresets(raw) { + const list = Array.isArray(raw) ? raw : FALLBACK_PRESETS; + const seen = new Set(); + const out = []; + for (const p of list) { + const width = parseInt(p?.width, 10); + const height = parseInt(p?.height, 10); + if (!width || !height || width < 1 || height < 1) continue; + const id = `${width}x${height}`; + if (seen.has(id)) continue; + seen.add(id); + out.push({ id, label: String(p.label || id), width, height }); + } + // Largest first — the picker reads top-down from best quality. + out.sort((a, b) => (b.width * b.height) - (a.width * a.height)); + return out; +} + +async function getDownloadGlobals() { + const now = Date.now(); + if (cache && now - cache.at < TTL_MS) return cache.val; + + const [standard, pickerEnabled, allowOriginal, presets] = await Promise.all([ + getAppSetting('download_standard_resolution', ORIGINAL), + getAppSetting('download_resolution_picker_enabled', false), + getAppSetting('download_allow_original', false), + getAppSetting('download_resolutions', FALLBACK_PRESETS), + ]); + + const val = { + standard_resolution: typeof standard === 'string' && standard ? standard : ORIGINAL, + picker_enabled: pickerEnabled === true, + allow_original: allowOriginal === true, + resolutions: normalisePresets(presets), + }; + cache = { at: now, val }; + return val; +} + +/** Clear the cache — call after any write to a download_* global. */ +function invalidateDownloadGlobals() { + cache = null; +} + +/** NULL/undefined = inherit the global; an explicit value wins. */ +function inherit(eventValue, globalValue) { + if (eventValue === null || eventValue === undefined) return globalValue; + return eventValue === true || eventValue === 1; +} + +/** + * The effective download policy for one event. + * + * Returns: + * standard resolution id handed out by every ordinary download + * standardBox {width,height} or null when standard is 'original' + * pickerEnabled whether the guest-facing modal is offered at all + * choices what the modal may offer, largest first + * + * `choices` is capped at the standard: a photographer who sets the standard to + * 1500x1000 is saying "this gallery hands out 1500px", so the picker must not + * quietly hand back something larger. 'Original' re-enters only when the admin + * explicitly allows it. + */ +async function resolveEventDownloadPolicy(event) { + const globals = await getDownloadGlobals(); + + const standard = (event && event.download_standard_resolution) + || globals.standard_resolution + || ORIGINAL; + const standardBox = parseResolution(standard); + + const pickerEnabled = inherit( + event ? event.download_resolution_picker_enabled : null, + globals.picker_enabled + ); + const allowOriginal = inherit( + event ? event.download_allow_original : null, + globals.allow_original + ); + + // Never offer a size above the standard, bounding EACH dimension rather + // than the pixel area: with mixed aspect ratios an area comparison lets + // e.g. 2000x700 (1.4MP) through under a 1500x1000 (1.5MP) standard, and the + // guest then gets a 2000px-wide rendition despite the stated 1500px cap. + // When the standard IS original, every preset qualifies. + const choices = globals.resolutions.filter((r) => !standardBox + || (r.width <= standardBox.width && r.height <= standardBox.height)); + + if (allowOriginal && standardBox) { + // The standard is capped but the admin opted into full-res downloads. + choices.unshift({ id: ORIGINAL, label: 'Original', width: null, height: null }); + } else if (!standardBox) { + // Standard is already original — it heads the list regardless, otherwise + // the picker couldn't offer what the plain download button already gives. + choices.unshift({ id: ORIGINAL, label: 'Original', width: null, height: null }); + } + + return { standard, standardBox, pickerEnabled, allowOriginal, choices }; +} + +/** + * Validate a guest-supplied resolution id against the event's policy. + * Returns the resolution id to actually use, or null when the request is not + * permitted — routes turn null into a 400 rather than silently downgrading, + * so a broken client is visible instead of quietly serving the wrong size. + */ +function pickRequestedResolution(policy, requested) { + if (!requested) return policy.standard; + if (!policy.pickerEnabled) return null; + const match = policy.choices.find((c) => c.id === requested); + return match ? match.id : null; +} + +module.exports = { + ORIGINAL, + getDownloadGlobals, + invalidateDownloadGlobals, + resolveEventDownloadPolicy, + pickRequestedResolution, + parseResolution, + resolutionId, +}; diff --git a/frontend/src/components/admin/DownloadResolutionCard.tsx b/frontend/src/components/admin/DownloadResolutionCard.tsx new file mode 100644 index 00000000..2724327d --- /dev/null +++ b/frontend/src/components/admin/DownloadResolutionCard.tsx @@ -0,0 +1,193 @@ +/** + * + * + * Per-event override for the download-resolution settings (#858). The globals + * live in Settings → Download resolutions; this card lets one gallery differ. + * + * Every field is tri-state — "Inherit" writes NULL and the gallery follows the + * global, matching the show_watermark / show_qr convention. The card shows the + * inherited value inline so an admin can see what "Inherit" currently means + * without leaving the page. + * + * Reads GET /api/admin/events/:id/download-resolutions (which returns the raw + * overrides, the globals, and the resolved effective policy) and saves through + * PATCH on the same path. + */ +import React, { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Download, Save } from 'lucide-react'; + +import { Button, Card, Loading } from '../common'; +import { api } from '../../config/api'; +import type { DownloadResolutionChoice } from '../../types'; + +const INHERIT = '__inherit__'; +const ORIGINAL = 'original'; + +interface Payload { + overrides: { + download_standard_resolution: string | null; + download_resolution_picker_enabled: boolean | null; + download_allow_original: boolean | null; + }; + globals: { + standard_resolution: string; + picker_enabled: boolean; + allow_original: boolean; + resolutions: DownloadResolutionChoice[]; + }; + effective: { + standard: string; + picker_enabled: boolean; + allow_original: boolean; + choices: DownloadResolutionChoice[]; + }; +} + +export interface DownloadResolutionCardProps { + eventId: number; + onChanged?: () => void; +} + +/** null → "Inherit"; true/false → explicit. */ +const triToSelect = (v: boolean | null | undefined) => + (v === null || v === undefined ? INHERIT : String(v)); +const selectToTri = (v: string) => (v === INHERIT ? null : v === 'true'); + +export const DownloadResolutionCard: React.FC = ({ eventId, onChanged }) => { + const { t } = useTranslation(); + const [standard, setStandard] = useState(INHERIT); + const [picker, setPicker] = useState(INHERIT); + const [allowOriginal, setAllowOriginal] = useState(INHERIT); + const [saving, setSaving] = useState(false); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['event-download-resolutions', eventId], + queryFn: async () => (await api.get(`/admin/events/${eventId}/download-resolutions`)).data, + }); + + useEffect(() => { + if (!data) return; + setStandard(data.overrides.download_standard_resolution ?? INHERIT); + setPicker(triToSelect(data.overrides.download_resolution_picker_enabled)); + setAllowOriginal(triToSelect(data.overrides.download_allow_original)); + }, [data]); + + if (isLoading || !data) { + return ; + } + + const globalStandardLabel = data.globals.standard_resolution === ORIGINAL + ? t('settings.downloads.original', 'Original (full size)') + : data.globals.standard_resolution; + + const save = async () => { + setSaving(true); + try { + await api.patch(`/admin/events/${eventId}/download-resolutions`, { + download_standard_resolution: standard === INHERIT ? null : standard, + download_resolution_picker_enabled: selectToTri(picker), + download_allow_original: selectToTri(allowOriginal), + }); + toast.success(t('settings.saved', 'Settings saved')); + await refetch(); + onChanged?.(); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; + toast.error(msg || t('settings.saveError', 'Failed to save settings')); + } finally { + setSaving(false); + } + }; + + // Same input styling as the other admin cards (SlideshowStyleFields) — + // notably the explicit text colour, without which the select renders + // muted and reads as disabled. + const selectClass = 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm'; + + return ( + +
+ +

+ {t('settings.downloads.eventTitle', 'Download resolution')} +

+
+

+ {t('settings.downloads.eventIntro', + 'Override the site-wide download settings for this gallery only. "Inherit" follows Settings → Download resolutions.')} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* What the gallery actually does right now, after the cascade. */} +

+ {t('settings.downloads.effective', 'Currently hands out: {{standard}}', { + standard: data.effective.standard === ORIGINAL + ? t('settings.downloads.original', 'Original (full size)') + : data.effective.standard, + })} + {data.effective.picker_enabled + ? ` · ${t('settings.downloads.pickerOn', 'guests may choose another size')}` + : ''} +

+ +
+ +
+
+ ); +}; diff --git a/frontend/src/components/gallery/DownloadResolutionModal.tsx b/frontend/src/components/gallery/DownloadResolutionModal.tsx new file mode 100644 index 00000000..8c33c97f --- /dev/null +++ b/frontend/src/components/gallery/DownloadResolutionModal.tsx @@ -0,0 +1,248 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Download, Check, AlertCircle, X, Loader2 } from 'lucide-react'; + +import { Button, Card } from '../common'; +import { galleryService } from '../../services/gallery.service'; +import type { DownloadResolutionChoice, DownloadJobStatus } from '../../types'; + +/** + * Resolution picker for gallery downloads (#858). + * + * Shown after "Download all"/"Download selected" when the gallery has the + * picker enabled. A non-standard size has nothing cached behind it and can + * take minutes to build, so the server prepares it as a job and this modal + * walks the three states the build actually has: + * + * choose → preparing (poll) → ready (click to download) + * + * The download itself is a native browser navigation, so the archive streams + * with Content-Length and the browser shows a real progress bar rather than + * us buffering a multi-GB blob in memory. + */ + +const POLL_MS = 1500; +// Give up after ~10 minutes of polling. The job may well still finish server +// side; this only stops the modal spinning forever in front of the user. +const MAX_POLLS = (10 * 60 * 1000) / POLL_MS; + +type Phase = 'choose' | 'preparing' | 'ready' | 'error'; + +interface DownloadResolutionModalProps { + slug: string; + choices: DownloadResolutionChoice[]; + /** The gallery's own standard size — served from the pre-built archive. */ + standardResolution?: string; + /** Omitted = the whole gallery. */ + photoIds?: number[]; + onClose: () => void; +} + +export const DownloadResolutionModal: React.FC = ({ + slug, + choices, + standardResolution, + photoIds, + onClose, +}) => { + const { t } = useTranslation(); + const [phase, setPhase] = useState('choose'); + const [selected, setSelected] = useState(choices[0]?.id ?? 'original'); + const [error, setError] = useState(null); + const [photoCount, setPhotoCount] = useState(0); + const tokenRef = useRef(null); + // Guards the polling loop against running on after unmount / close. + const activeRef = useRef(true); + + useEffect(() => () => { activeRef.current = false; }, []); + + const filename = `${slug}-${selected === 'original' ? 'original' : selected}.zip`; + + const poll = useCallback(async (token: string) => { + for (let i = 0; i < MAX_POLLS; i += 1) { + if (!activeRef.current) return; + await new Promise((r) => setTimeout(r, POLL_MS)); + if (!activeRef.current) return; + try { + const state = await galleryService.getDownloadJob(slug, token); + setPhotoCount(state.photo_count || 0); + if (state.status === 'ready') { + setPhase('ready'); + return; + } + if (state.status === 'failed') { + setError(state.error || t('gallery.downloadPrepFailed', 'Preparation failed')); + setPhase('error'); + return; + } + } catch { + setError(t('gallery.downloadPrepFailed', 'Preparation failed')); + setPhase('error'); + return; + } + } + setError(t('gallery.downloadPrepTimeout', 'This is taking longer than expected. Please try again.')); + setPhase('error'); + }, [slug, t]); + + const start = useCallback(async () => { + // Whole-gallery download at the gallery's OWN standard size is exactly + // what the pre-built archive already contains — take it instead of + // re-resizing and re-packaging the entire gallery for the same bytes. + if (!photoIds && selected === standardResolution) { + await galleryService.downloadAllPhotos(slug, true); + onClose(); + return; + } + + setPhase('preparing'); + setError(null); + try { + const job = await galleryService.startDownloadJob(slug, selected, photoIds); + tokenRef.current = job.token; + // A job can be deduped onto an already-finished build, in which case + // there is nothing to wait for. + if ((job.status as DownloadJobStatus) === 'ready') { + setPhase('ready'); + return; + } + await poll(job.token); + } catch { + setError(t('gallery.downloadPrepFailed', 'Preparation failed')); + setPhase('error'); + } + }, [slug, selected, photoIds, poll, t, standardResolution, onClose]); + + const download = useCallback(() => { + if (!tokenRef.current) return; + galleryService.downloadJobFile(slug, tokenRef.current, filename); + onClose(); + }, [slug, filename, onClose]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + return ( +
+ e.stopPropagation()} + > +
+

+ {t('gallery.chooseResolution', 'Choose a download size')} +

+ +
+ + {phase === 'choose' && ( + <> +
+ {choices.map((choice) => ( + + ))} +
+
+ + +
+ + )} + + {phase === 'preparing' && ( +
+ +

+ {t('gallery.preparingDownload', 'Preparing your download…')} +

+

+ {photoCount > 0 + ? t('gallery.preparingProgress', '{{count}} photos packaged', { count: photoCount }) + : t('gallery.preparingHint', 'Resizing photos — this can take a moment for large galleries.')} +

+
+ )} + + {phase === 'ready' && ( +
+
+ +
+

+ {t('gallery.downloadReady', 'Your download is ready')} +

+ +
+ )} + + {phase === 'error' && ( +
+ +

{error}

+
+ + +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 5cc75683..78fe1bfe 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -8,6 +8,7 @@ import { GallerySkeleton } from './GallerySkeleton'; import { useGalleryAuth, useTheme } from '../../contexts'; import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; import { PhotoGridWithLayouts } from './PhotoGridWithLayouts'; +import { DownloadResolutionModal } from './DownloadResolutionModal'; import { ExpirationBanner } from './ExpirationBanner'; import { CountdownTimer } from './CountdownTimer'; import { GalleryLayout } from './GalleryLayout'; @@ -73,6 +74,10 @@ export const GalleryView: React.FC = ({ slug, event }) => { const { setTheme, theme } = useTheme(); const queryClient = useQueryClient(); const [selectedCategoryId, setSelectedCategoryId] = useState(null); + // Download size picker (#858). `showResolutionPicker` covers "download all"; + // `resolutionPickerIds` covers a selection (sidebar / full-page layouts). + const [showResolutionPicker, setShowResolutionPicker] = useState(false); + const [resolutionPickerIds, setResolutionPickerIds] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date'); const [sortDesc, setSortDesc] = useState(true); @@ -607,12 +612,25 @@ export const GalleryView: React.FC = ({ slug, event }) => { // Check if downloads are allowed (both event setting and not expired) const allowDownloads = !isExpired && (data?.event?.allow_downloads === true); + // Resolution picker choices (#858). More than one option means there is an + // actual choice to make; a single option is just the standard size, so skip + // the modal and download straight away. + const downloadChoices = data?.event?.download_resolution?.picker_enabled + ? (data.event.download_resolution.choices || []) + : []; + const handleDownloadAll = () => { // Prevent downloads if gallery is expired or downloads disabled if (!allowDownloads) { return; } - + + // Hand off to the picker; it builds the archive as a job and downloads it. + if (downloadChoices.length > 1) { + setShowResolutionPicker(true); + return; + } + downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready }); // Track download all action @@ -625,12 +643,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { const handleDownloadSelected = async () => { if (selectedPhotos.size === 0) return; - + // Prevent downloads if gallery is expired or downloads disabled if (!allowDownloads) { return; } - + + // Resolution picker (#858): sidebar-driven selections get the same choice + // as the grid's own control, rather than silently downloading at the + // gallery standard. + if (downloadChoices.length > 1) { + setResolutionPickerIds(Array.from(selectedPhotos)); + return; + } + const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id)); // Track bulk download @@ -771,6 +797,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { onClose={() => setShowUploadModal(false)} /> )} + + {/* Download size picker (#858) — "download all", or a selection. */} + {(showResolutionPicker || resolutionPickerIds) && ( + { + setShowResolutionPicker(false); + setResolutionPickerIds(null); + }} + /> + )} ); } @@ -814,6 +854,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { eventDate={event.event_date} expiresAt={event.expires_at} allowDownloads={allowDownloads} + downloadChoices={downloadChoices} + downloadStandard={data?.event?.download_resolution?.standard} protectionLevel={protectionLevel} useEnhancedProtection={protectionLevel !== 'basic'} disableRightClick={disableRightClick} @@ -842,6 +884,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { onClose={() => setShowUploadModal(false)} /> )} + + {/* Download size picker (#858) — "download all", or a selection. */} + {(showResolutionPicker || resolutionPickerIds) && ( + { + setShowResolutionPicker(false); + setResolutionPickerIds(null); + }} + /> + )} ); } @@ -1071,6 +1127,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { eventDate={event.event_date} expiresAt={event.expires_at} allowDownloads={allowDownloads} + downloadChoices={downloadChoices} + downloadStandard={data?.event?.download_resolution?.standard} protectionLevel={protectionLevel} useEnhancedProtection={protectionLevel !== 'basic'} disableRightClick={disableRightClick} @@ -1102,6 +1160,20 @@ export const GalleryView: React.FC = ({ slug, event }) => { onClose={() => setShowUploadModal(false)} /> )} + + {/* Download size picker (#858) — "download all", or a selection. */} + {(showResolutionPicker || resolutionPickerIds) && ( + { + setShowResolutionPicker(false); + setResolutionPickerIds(null); + }} + /> + )} diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 214b70ba..eb0dade6 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -3,9 +3,10 @@ import { Package } from 'lucide-react'; import { toast as toastify } from 'react-toastify'; import { useTranslation } from 'react-i18next'; -import type { Photo } from '../../types'; +import type { Photo, DownloadResolutionChoice } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; import { PhotoLightbox } from './PhotoLightbox'; +import { DownloadResolutionModal } from './DownloadResolutionModal'; import { Button } from '../common'; import { galleryService } from '../../services/gallery.service'; import { analyticsService } from '../../services/analytics.service'; @@ -42,6 +43,10 @@ interface PhotoGridWithLayoutsProps { expiresAt?: string | null; feedbackEnabled?: boolean; allowDownloads?: boolean; + // Resolution picker choices (#858). Empty/absent = no picker, download + // straight at the gallery's standard size. + downloadChoices?: DownloadResolutionChoice[]; + downloadStandard?: string; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; useCanvasRendering?: boolean; @@ -87,6 +92,8 @@ export const PhotoGridWithLayouts: React.FC = ({ feedbackOptions, onFeedbackChange, allowDownloads = true, + downloadChoices, + downloadStandard, protectionLevel = 'standard', useEnhancedProtection = false, useCanvasRendering = false, @@ -117,6 +124,8 @@ export const PhotoGridWithLayouts: React.FC = ({ const [openFeedbackInitially, setOpenFeedbackInitially] = useState(false); const [localSelectedPhotos, setLocalSelectedPhotos] = useState>(new Set()); const [localSelectionMode, setLocalSelectionMode] = useState(false); + // Non-null while the resolution picker is open (#858); holds the ids it applies to. + const [resolutionPickerIds, setResolutionPickerIds] = useState(null); const downloadPhotoMutation = useDownloadPhoto(); // Use parent state if provided, otherwise use local state @@ -183,6 +192,14 @@ export const PhotoGridWithLayouts: React.FC = ({ const handleDownloadSelected = async () => { if (selectedPhotos.size === 0) return; const ids = Array.from(selectedPhotos); + + // Resolution picker (#858): when the gallery offers a choice, hand off to + // the modal — it drives the job build and does the download itself. + if (downloadChoices && downloadChoices.length > 1) { + setResolutionPickerIds(ids); + return; + } + toastify.info(t('gallery.downloading', { count: ids.length })); try { @@ -215,6 +232,11 @@ export const PhotoGridWithLayouts: React.FC = ({ const layoutProps = { photos, slug, + // Full-page layouts own their bulk-download control, so the resolution + // picker has to reach them too (#858) — otherwise premium/story galleries + // silently skip the choice the admin enabled. + downloadChoices, + onPickResolution: (ids: number[]) => setResolutionPickerIds(ids), onPhotoClick: handlePhotoClick, onOpenPhotoWithFeedback: handleOpenWithFeedback, onFeedbackChange: onFeedbackChange, @@ -387,6 +409,25 @@ export const PhotoGridWithLayouts: React.FC = ({ showOriginalFilename={showOriginalFilename} /> )} + + {/* Download size picker (#858) — drives the job build and the download. */} + {resolutionPickerIds && downloadChoices && ( + { + setResolutionPickerIds(null); + setSelectedPhotos(new Set()); + if (parentToggleSelectionMode) { + parentToggleSelectionMode(); + } else { + setLocalSelectionMode(false); + } + }} + /> + )} ); }; diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index c97bf163..1476af7b 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { Photo } from '../../../types'; +import type { Photo, DownloadResolutionChoice } from '../../../types'; export interface BaseGalleryLayoutProps { photos: Photo[]; @@ -20,6 +20,11 @@ export interface BaseGalleryLayoutProps { eventDate?: string | null; expiresAt?: string | null; allowDownloads?: boolean; + // Resolution picker choices (#858). More than one entry means the gallery + // offers a real choice, so bulk downloads must route through the modal + // instead of calling downloadSelectedPhotos directly. + downloadChoices?: DownloadResolutionChoice[]; + onPickResolution?: (photoIds: number[]) => void; protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum'; useEnhancedProtection?: boolean; useCanvasRendering?: boolean; diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 8261d4c5..b293e07c 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -183,6 +183,8 @@ export const GalleryPremiumLayout: React.FC = ({ eventName, eventDate, allowDownloads = true, + downloadChoices, + onPickResolution, protectionLevel = 'standard', useEnhancedProtection = false, useCanvasRendering = false, @@ -398,6 +400,11 @@ export const GalleryPremiumLayout: React.FC = ({ const handleDownloadSelected = useCallback(async () => { if (selectedPhotos.size === 0) return; const ids = Array.from(selectedPhotos); + // #858: hand off to the resolution picker when the gallery offers a choice. + if (downloadChoices && downloadChoices.length > 1 && onPickResolution) { + onPickResolution(ids); + return; + } toast.info(t('gallery.downloading', { count: ids.length })); try { @@ -406,7 +413,7 @@ export const GalleryPremiumLayout: React.FC = ({ } catch { toast.error(t('gallery.downloadError')); } - }, [selectedPhotos, slug, t]); + }, [selectedPhotos, slug, t, downloadChoices, onPickResolution]); const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => { if (!allowDownloads || !slide.src) return; diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index 9f4f0e1e..a3580075 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -51,6 +51,8 @@ export const GalleryStoryLayout: React.FC = ({ eventName, eventDate, allowDownloads = true, + downloadChoices, + onPickResolution, protectionLevel = 'standard', useEnhancedProtection = false, useCanvasRendering = false, @@ -232,15 +234,20 @@ export const GalleryStoryLayout: React.FC = ({ }, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]); const handleDownloadAll = useCallback(async () => { + const ids = photos.map(p => p.id); + // #858: hand off to the resolution picker when the gallery offers a choice. + if (downloadChoices && downloadChoices.length > 1 && onPickResolution) { + onPickResolution(ids); + return; + } toast.info(t('gallery.downloading', { count: photos.length })); try { - const ids = photos.map(p => p.id); await galleryService.downloadSelectedPhotos(slug, ids); analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length }); } catch { toast.error(t('gallery.downloadError')); } - }, [photos, slug, t]); + }, [photos, slug, t, downloadChoices, onPickResolution]); if (photos.length === 0) { return ( diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 3013b13a..b556eff1 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -16,6 +16,7 @@ export { ModerationTab } from './tabs/ModerationTab'; export { StylingTab } from './tabs/StylingTab'; export { SEOTab } from './tabs/SEOTab'; export { ThumbnailsTab } from './tabs/ThumbnailsTab'; +export { DownloadsTab } from './tabs/DownloadsTab'; export { ApiTokensTab } from './tabs/ApiTokensTab'; export { WebhooksTab } from './tabs/WebhooksTab'; export { AccountingTab } from './tabs/AccountingTab'; diff --git a/frontend/src/features/settings/tabs/DownloadsTab.tsx b/frontend/src/features/settings/tabs/DownloadsTab.tsx new file mode 100644 index 00000000..2ea6135d --- /dev/null +++ b/frontend/src/features/settings/tabs/DownloadsTab.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState } from 'react'; +import { Save, Download, Plus, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; + +import { Button, Card, Input, Loading } from '../../../components/common'; +import { api } from '../../../config/api'; + +/** + * Download resolutions (#858). + * + * The STANDARD resolution is what every ordinary download hands out — single + * photos, selected photos and download-all alike. The PICKER is an opt-in + * modal letting guests choose a different size; those archives are built on + * demand rather than served from the cache. + * + * Both are global defaults here; individual galleries can override them. + */ + +interface Preset { + id?: string; + label: string; + width: number; + height: number; +} + +interface DownloadSettings { + standard_resolution: string; + picker_enabled: boolean; + allow_original: boolean; + resolutions: Preset[]; +} + +const ORIGINAL = 'original'; + +export const DownloadsTab: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [form, setForm] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-download-settings'], + queryFn: async () => (await api.get('/admin/settings/downloads')).data, + }); + + useEffect(() => { + if (data) setForm(data); + }, [data]); + + const save = useMutation({ + mutationFn: async (payload: DownloadSettings) => { + await api.put('/admin/settings/downloads', { + download_standard_resolution: payload.standard_resolution, + download_resolution_picker_enabled: payload.picker_enabled, + download_allow_original: payload.allow_original, + download_resolutions: payload.resolutions.map((r) => ({ + label: r.label, width: r.width, height: r.height, + })), + }); + }, + onSuccess: () => { + toast.success(t('settings.saved', 'Settings saved')); + queryClient.invalidateQueries({ queryKey: ['admin-download-settings'] }); + }, + onError: (e: unknown) => { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error; + toast.error(msg || t('settings.saveError', 'Failed to save settings')); + }, + }); + + // Matches SlideshowStyleFields' input styling — the explicit text colour + // matters, without it the select renders muted and looks disabled. + const selectClass = 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm'; + + if (isLoading || !form) return ; + + const setPreset = (i: number, patch: Partial) => { + const old = form.resolutions[i]; + const next = { ...old, ...patch }; + const resolutions = form.resolutions.map((r, idx) => (idx === i ? next : r)); + // A preset's id IS its dimensions, so editing width/height of the preset + // that is currently the standard would leave standard_resolution pointing + // at an id that no longer exists and the save would 400. Follow the edit. + const standard = `${old.width}x${old.height}` === form.standard_resolution + ? `${next.width}x${next.height}` + : form.standard_resolution; + setForm({ ...form, resolutions, standard_resolution: standard }); + }; + + const removePreset = (i: number) => { + const removed = form.resolutions[i]; + const resolutions = form.resolutions.filter((_, idx) => idx !== i); + // Keep the invariant the API enforces: the standard must stay a real + // preset, otherwise the save is rejected. + const standard = `${removed.width}x${removed.height}` === form.standard_resolution + ? ORIGINAL + : form.standard_resolution; + setForm({ ...form, resolutions, standard_resolution: standard }); + }; + + const addPreset = () => setForm({ + ...form, + resolutions: [...form.resolutions, { label: 'Custom', width: 2000, height: 1500 }], + }); + + return ( +
+ +
+ +

+ {t('settings.downloads.title', 'Download resolutions')} +

+
+

+ {t('settings.downloads.intro', + 'The standard size is what every gallery hands out by default. Individual galleries can override this.')} +

+ + + + + + + + +
+
+

+ {t('settings.downloads.presets', 'Available sizes')} +

+ +
+

+ {t('settings.downloads.presetsHint', + 'Sizes are an upper bound — the aspect ratio is kept and photos are never enlarged.')} +

+ +
+ {form.resolutions.map((r, i) => ( +
+ setPreset(i, { label: e.target.value })} + className="flex-1" + aria-label={t('settings.downloads.label', 'Label')} + /> + setPreset(i, { width: parseInt(e.target.value, 10) || 0 })} + className="w-28" + aria-label={t('settings.downloads.width', 'Width')} + /> + × + setPreset(i, { height: parseInt(e.target.value, 10) || 0 })} + className="w-28" + aria-label={t('settings.downloads.height', 'Height')} + /> + +
+ ))} +
+
+ +
+ +
+
+
+ ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 758c7afc..5dff1a9e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -153,7 +153,9 @@ "showAll": "Alle anzeigen", "confirm": "Bestätigen", "show": "Einblenden", - "poweredBy": "Bereitgestellt von" + "poweredBy": "Bereitgestellt von", + "on": "an", + "off": "aus" }, "upload": { "photoCategory": "Fotokategorie", @@ -1044,7 +1046,17 @@ "socials": "Soziale Netzwerke" }, "photosCount_one": "{{count}} Foto", - "photosCount_other": "{{count}} Fotos" + "photosCount_other": "{{count}} Fotos", + "chooseResolution": "Downloadgröße wählen", + "resolutionUpTo": "bis zu {{width}} × {{height}} px", + "prepareDownload": "Download vorbereiten", + "preparingDownload": "Download wird vorbereitet…", + "preparingHint": "Fotos werden verkleinert — bei großen Galerien kann das einen Moment dauern.", + "preparingProgress": "{{count}} Fotos verpackt", + "downloadReady": "Ihr Download ist bereit", + "downloadNow": "Herunterladen", + "downloadPrepFailed": "Vorbereitung fehlgeschlagen", + "downloadPrepTimeout": "Das dauert länger als erwartet. Bitte erneut versuchen." }, "categories": { "title": "Fotokategorien", @@ -2284,6 +2296,26 @@ "logoutFromIdpHint": "Die Abmeldung von PicPeak beendet auch die IdP-Sitzung (RP-initiated Logout). Gilt nur für Sitzungen, die per SSO angemeldet wurden; ohne diese Option bleibt die IdP-Sitzung bestehen und der nächste SSO-Klick meldet direkt wieder an.", "postLogoutRedirectUri": "Post-Logout-Redirect-URI (beim IdP-Client registrieren, z. B. Keycloak „Valid post logout redirect URIs“)" } + }, + "downloads": { + "title": "Download-Auflösungen", + "intro": "Die Standardgröße gibt jede Galerie standardmäßig heraus. Einzelne Galerien können das überschreiben.", + "standard": "Standardauflösung", + "original": "Original (volle Größe)", + "picker": "Gäste die Downloadgröße wählen lassen", + "pickerHint": "Fügt Sammel-Downloads eine Größenauswahl hinzu. Eigene Größen werden bei Bedarf erzeugt und sind nie größer als der Standard.", + "allowOriginal": "„Original“ in der Auswahl anbieten", + "allowOriginalHint": "Standardmäßig aus: Wer die Standardgröße reduziert, möchte in der Regel keine Dateien in voller Auflösung herausgeben.", + "presets": "Verfügbare Größen", + "presetsHint": "Größen sind eine Obergrenze — das Seitenverhältnis bleibt erhalten und Fotos werden nie vergrößert.", + "label": "Bezeichnung", + "width": "Breite", + "height": "Höhe", + "eventTitle": "Download-Auflösung", + "eventIntro": "Überschreibt die globalen Download-Einstellungen nur für diese Galerie. „Erben“ folgt Einstellungen → Download-Auflösungen.", + "inheritWith": "Erben ({{value}})", + "effective": "Gibt derzeit heraus: {{standard}}", + "pickerOn": "Gäste dürfen eine andere Größe wählen" } }, "branding": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7a3fdbfd..6866789a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -153,7 +153,9 @@ "showAll": "Show all", "confirm": "Confirm", "show": "Show", - "poweredBy": "Powered by" + "poweredBy": "Powered by", + "on": "on", + "off": "off" }, "upload": { "photoCategory": "Photo Category", @@ -589,7 +591,17 @@ "photosSelected_one": "{{count}} photo selected", "photosSelected_other": "{{count}} photos selected", "downloadSelected_one": "Download {{count}} photo", - "downloadSelected_other": "Download {{count}} photos" + "downloadSelected_other": "Download {{count}} photos", + "chooseResolution": "Choose a download size", + "resolutionUpTo": "up to {{width}} × {{height}} px", + "prepareDownload": "Prepare download", + "preparingDownload": "Preparing your download…", + "preparingHint": "Resizing photos — this can take a moment for large galleries.", + "preparingProgress": "{{count}} photos packaged", + "downloadReady": "Your download is ready", + "downloadNow": "Download", + "downloadPrepFailed": "Preparation failed", + "downloadPrepTimeout": "This is taking longer than expected. Please try again." }, "categories": { "title": "Photo Categories", @@ -1829,6 +1841,26 @@ "logoutFromIdpHint": "Logging out of PicPeak also ends the IdP session (RP-initiated logout). Only applies to sessions that signed in via SSO; without this, logging out of PicPeak leaves the IdP session alive and the next SSO click signs straight back in.", "postLogoutRedirectUri": "Post-logout redirect URI (register this on your IdP client, e.g. Keycloak \"Valid post logout redirect URIs\")" } + }, + "downloads": { + "title": "Download resolutions", + "intro": "The standard size is what every gallery hands out by default. Individual galleries can override this.", + "standard": "Standard resolution", + "original": "Original (full size)", + "picker": "Let guests choose a download size", + "pickerHint": "Adds a size picker to bulk downloads. Custom sizes are prepared on demand and are never larger than the standard.", + "allowOriginal": "Offer \"Original\" in the picker", + "allowOriginalHint": "Off by default: lowering the standard size normally means full-resolution files should not be handed out.", + "presets": "Available sizes", + "presetsHint": "Sizes are an upper bound — the aspect ratio is kept and photos are never enlarged.", + "label": "Label", + "width": "Width", + "height": "Height", + "eventTitle": "Download resolution", + "eventIntro": "Override the site-wide download settings for this gallery only. \"Inherit\" follows Settings → Download resolutions.", + "inheritWith": "Inherit ({{value}})", + "effective": "Currently hands out: {{standard}}", + "pickerOn": "guests may choose another size" } }, "analytics": { diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index c9d66e91..acdebae4 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -11,6 +11,7 @@ import { Image as ImageIcon, Search, Tags, + Download as DownloadIcon, Tag, BarChart3, Flag, @@ -38,6 +39,7 @@ import { StylingTab, SEOTab, ThumbnailsTab, + DownloadsTab, ApiTokensTab, WebhooksTab, AccountingTab, @@ -68,6 +70,7 @@ type TabType = | 'branding' | 'categories' | 'thumbnails' + | 'downloads' | 'styling' | 'cms' | 'email' @@ -104,7 +107,7 @@ interface NavGroup { const ALL_TAB_KEYS: TabType[] = [ 'features', 'general', 'events', 'eventTypes', - 'branding', 'categories', 'thumbnails', 'styling', 'cms', + 'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms', 'email', 'moderation', 'security', 'sso', 'imageSecurity', 'seo', 'apiTokens', 'webhooks', @@ -244,6 +247,7 @@ export const SettingsPage: React.FC = () => { { key: 'branding', label: t('settings.branding.title', 'Branding'), icon: Palette }, { key: 'categories', label: t('settings.categories.title'), icon: Tags }, { key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon }, + { key: 'downloads', label: t('settings.downloads.title', 'Download resolutions'), icon: DownloadIcon }, { key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code }, { key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText }, ...(flags.slideshow @@ -495,6 +499,7 @@ export const SettingsPage: React.FC = () => { {activeTab === 'imageSecurity' && } {activeTab === 'thumbnails' && } + {activeTab === 'downloads' && } {activeTab === 'categories' && } {activeTab === 'analytics' && ( diff --git a/frontend/src/pages/admin/event-details/OverviewTab.tsx b/frontend/src/pages/admin/event-details/OverviewTab.tsx index 9cede3b4..43b5bba8 100644 --- a/frontend/src/pages/admin/event-details/OverviewTab.tsx +++ b/frontend/src/pages/admin/event-details/OverviewTab.tsx @@ -3,6 +3,7 @@ import type { Event } from '../../../types'; import { FeedbackModerationPanel } from '../../../components/admin'; import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard'; import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard'; +import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard'; import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import type { AdminPhoto } from '../../../services/photos.service'; @@ -116,6 +117,10 @@ export const OverviewTab: React.FC = ({ {/* Client Access (#172) */} + {/* Per-gallery download resolution override (#858). Sits with the + other "what the customer receives" controls. */} + refetchEvent()} /> + {/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139). Gated behind the `slideshow` feature flag. */} {flags.slideshow && ( diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index 13a41d3c..0fb3a295 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -1,5 +1,8 @@ import { api } from '../config/api'; -import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types'; +import type { + GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier, + DownloadJobStatus, DownloadJobState, +} from '../types'; import { normalizeRequirePassword } from '../utils/accessControl'; import { parseContentDispositionFilename } from '../utils/contentDisposition'; @@ -280,6 +283,39 @@ export const galleryService = { window.URL.revokeObjectURL(url); }, + // ── Custom-resolution downloads (#858) ────────────────────────────────── + // A non-standard resolution has nothing cached behind it and can take + // minutes to build, so the server does it as a job we poll rather than + // holding a request open past the proxy timeout. + + // Kick off a build. `photoIds` omitted = the whole gallery. + async startDownloadJob( + slug: string, + resolution: string, + photoIds?: number[] + ): Promise<{ token: string; status: DownloadJobStatus }> { + const body: Record = { resolution }; + if (photoIds && photoIds.length) body.photo_ids = photoIds; + const response = await api.post(`/gallery/${slug}/download-jobs`, body); + return response.data; + }, + + async getDownloadJob(slug: string, token: string): Promise { + const response = await api.get(`/gallery/${slug}/download-jobs/${token}`); + return response.data; + }, + + // Native browser download so the archive streams with Content-Length + // (real progress bar, no in-memory blob for a multi-GB gallery). + downloadJobFile(slug: string, token: string, filename: string): void { + const link = document.createElement('a'); + link.href = withAdminPreview(`/api/gallery/${slug}/download-jobs/${token}/file`); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + }, + // iOS-only Web Share path for a selection of photos. // // Returns: diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ae20e76e..2fe5779f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -173,6 +173,24 @@ export interface Photo { favorite_count?: number; } +// Download resolutions (#858). +export type DownloadJobStatus = 'pending' | 'building' | 'ready' | 'failed'; + +export interface DownloadResolutionChoice { + id: string; // 'original' | 'x' + label: string; + width: number | null; + height: number | null; +} + +export interface DownloadJobState { + status: DownloadJobStatus; + resolution: string; + photo_count: number; + size_bytes: number | null; + error?: string; +} + export interface PhotoCategory { id: number | string; name: string; @@ -187,6 +205,13 @@ export interface GalleryData { event_name: string; event_type: string; event_date: string | null; + // Download resolutions (#858). `choices` is empty when the picker is off, + // so the UI never offers a size the server would reject. + download_resolution?: { + standard: string; + picker_enabled: boolean; + choices: DownloadResolutionChoice[]; + }; welcome_message?: string; color_theme?: string; expires_at: string | null;