Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default. STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries. RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it. Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input. Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats. Closes #858.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' | '<width>x<height>' 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 '<width>x<height>'.
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@ const { initializeDatabase, db } = require('./src/database/db');
|
|||||||
const { startFileWatcher } = require('./src/services/fileWatcher');
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
||||||
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||||
const { startTransferCleanup } = require('./src/services/transferCleanupService');
|
const { startTransferCleanup } = require('./src/services/transferCleanupService');
|
||||||
|
const { startDownloadJobCleanup } = require('./src/services/downloadJobCleanupService');
|
||||||
const { startRevealScheduler } = require('./src/services/revealScheduler');
|
const { startRevealScheduler } = require('./src/services/revealScheduler');
|
||||||
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
||||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||||
@@ -912,6 +913,9 @@ async function startServer() {
|
|||||||
// PicTransfer retention sweep (#997): expire links, notify admins, and
|
// PicTransfer retention sweep (#997): expire links, notify admins, and
|
||||||
// hard-delete client uploads once the grace window elapses.
|
// hard-delete client uploads once the grace window elapses.
|
||||||
startTransferCleanup();
|
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.
|
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
||||||
startRevealScheduler();
|
startRevealScheduler();
|
||||||
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
require('./crud')(router);
|
require('./crud')(router);
|
||||||
require('./slideshow')(router);
|
require('./slideshow')(router);
|
||||||
|
require('./downloadResolutions')(router);
|
||||||
require('./resets')(router);
|
require('./resets')(router);
|
||||||
require('./archiveBulk')(router);
|
require('./archiveBulk')(router);
|
||||||
require('./logo')(router);
|
require('./logo')(router);
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ const RESERVED_SETTING_KEYS = [
|
|||||||
// clobbered with plaintext, and the policy/mapping keys carry invariants
|
// clobbered with plaintext, and the policy/mapping keys carry invariants
|
||||||
// (role targets exist, break-glass account present) that only the dedicated
|
// (role targets exist, break-glass account present) that only the dedicated
|
||||||
// PUT /sso validates — a generic upsert would bypass all of them.
|
// 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) => {
|
const stripReservedSettingKeys = (settings) => {
|
||||||
for (const key of Object.keys(settings)) {
|
for (const key of Object.keys(settings)) {
|
||||||
if (isReservedSettingKey(key)) delete settings[key];
|
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
|
// Get settings by type
|
||||||
// ──────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
|
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
|
||||||
|
|||||||
+247
-32
@@ -35,8 +35,17 @@ const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
|||||||
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
|
const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode');
|
||||||
const { toIso } = require('../utils/dateNormalize');
|
const { toIso } = require('../utils/dateNormalize');
|
||||||
const { NotFoundError } = require('../utils/errors');
|
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 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 { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility');
|
||||||
const {
|
const {
|
||||||
getUseOriginalFilenames,
|
getUseOriginalFilenames,
|
||||||
@@ -885,6 +894,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
const useOriginalFilenames = await getUseOriginalFilenames();
|
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||||
|
const downloadPolicy = await resolveEventDownloadPolicy(req.event);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
event: {
|
event: {
|
||||||
@@ -898,6 +908,14 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
|||||||
hero_photo_id: req.event.hero_photo_id,
|
hero_photo_id: req.event.hero_photo_id,
|
||||||
allow_downloads: req.event.allow_downloads !== false,
|
allow_downloads: req.event.allow_downloads !== false,
|
||||||
allow_user_uploads: req.event.allow_user_uploads === true,
|
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
|
// Reveal mode (#838): armed flag lets an open VISIBLE gallery keep
|
||||||
// polling so a re-hide propagates without a manual reload.
|
// 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',
|
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 +
|
// Admin preview (#868) downloads are excluded from the download count +
|
||||||
// guest analytics — kept out of client-facing stats.
|
// guest analytics — kept out of client-facing stats.
|
||||||
if (!req.isAdminPreview) {
|
if (!req.isAdminPreview) {
|
||||||
@@ -1169,23 +1199,42 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
|||||||
const downloadName = pickRawDownloadName(photo, useOriginal);
|
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||||
const contentDisposition = buildContentDisposition(downloadName);
|
const contentDisposition = buildContentDisposition(downloadName);
|
||||||
|
|
||||||
if (shouldApplyWatermark) {
|
// The gallery's standard applies to EVERY ordinary download, single photos
|
||||||
// Apply watermark and send
|
// included — otherwise a lowered standard is trivially bypassed by
|
||||||
// Use event watermark text if available, otherwise fall back to global settings
|
// downloading photos one at a time. `box` was resolved above, before the
|
||||||
const effectiveSettings = {
|
// 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,
|
...watermarkSettings,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||||
};
|
} : null;
|
||||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
|
|
||||||
|
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({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
'Content-Disposition': contentDisposition,
|
'Content-Disposition': contentDisposition,
|
||||||
'Content-Length': watermarkedBuffer.length
|
'Content-Length': buffer.length
|
||||||
});
|
});
|
||||||
|
|
||||||
res.send(watermarkedBuffer);
|
res.send(buffer);
|
||||||
} else {
|
} else {
|
||||||
// res.download() builds Content-Disposition itself but doesn't emit the
|
// res.download() builds Content-Disposition itself but doesn't emit the
|
||||||
// RFC 5987 filename* parameter, so unicode camera filenames would lose
|
// 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'
|
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
|
||||||
} : null;
|
} : 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.
|
// Add photos to archive — managed photos via storage backend, external via local path.
|
||||||
const { resolvePhotoStorageKey } = require('../services/photoResolver');
|
const { resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -1408,21 +1461,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, block
|
|||||||
throw new Error('Photo file missing on disk');
|
throw new Error('Photo file missing on disk');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldApplyWatermark && effectiveSettings) {
|
// Resize to the gallery's standard resolution (#858) and/or watermark.
|
||||||
// Watermark service operates on a local path. For managed photos in
|
// This branch runs whenever the cached zip isn't usable — the first
|
||||||
// S3 mode, materialize a tmp local copy first.
|
// download after an invalidation, PIN clients, and galleries with
|
||||||
const { withLocalCopy } = require('../services/imageProcessor');
|
// hidden photos all land here, so skipping the cap would leak
|
||||||
const sourceForWatermark = storageKey
|
// full-resolution files for exactly those cases.
|
||||||
? null
|
const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings);
|
||||||
: resolvePhotoFilePath(req.event, photo);
|
if (rendered) {
|
||||||
|
archive.append(rendered, { name: archiveName });
|
||||||
const watermarkedBuffer = storageKey
|
|
||||||
? await withLocalCopy(storageKey, (localPath) =>
|
|
||||||
watermarkService.applyWatermark(localPath, effectiveSettings)
|
|
||||||
)
|
|
||||||
: await watermarkService.applyWatermark(sourceForWatermark, effectiveSettings);
|
|
||||||
|
|
||||||
archive.append(watermarkedBuffer, { name: archiveName });
|
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
const stream = await storage.get(storageKey);
|
const stream = await storage.get(storageKey);
|
||||||
archive.append(stream, { name: archiveName });
|
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' });
|
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`;
|
const archiveName = `${req.event.slug}-selected.zip`;
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||||
@@ -1545,7 +1600,6 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
|
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
|
||||||
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
|
|
||||||
const selectedStorage = getStorage();
|
const selectedStorage = getStorage();
|
||||||
// #493: same display-name resolution as bulk download, with dedup.
|
// #493: same display-name resolution as bulk download, with dedup.
|
||||||
const useOriginalSelected = await getUseOriginalFilenames();
|
const useOriginalSelected = await getUseOriginalFilenames();
|
||||||
@@ -1570,13 +1624,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
|||||||
throw new Error('Photo file missing on disk');
|
throw new Error('Photo file missing on disk');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldApplyWatermark && effectiveSettings) {
|
// Resize (#858) and/or watermark. renderPhotoForDownload returns null
|
||||||
const buf = storageKey
|
// when neither applies, so the untransformed case still streams from
|
||||||
? await withSelectedLocalCopy(storageKey, (lp) =>
|
// storage rather than buffering the whole photo.
|
||||||
watermarkService.applyWatermark(lp, effectiveSettings)
|
const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings);
|
||||||
)
|
if (rendered) {
|
||||||
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), effectiveSettings);
|
archive.append(rendered, { name });
|
||||||
archive.append(buf, { name });
|
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
const stream = await selectedStorage.get(storageKey);
|
const stream = await selectedStorage.get(storageKey);
|
||||||
archive.append(stream, { name });
|
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-
|
// Explicit per-photo view beacon (#895). Counting views on the image-
|
||||||
// serving routes is wrong in both directions: the lightbox preloads the
|
// serving routes is wrong in both directions: the lightbox preloads the
|
||||||
// prev/next neighbours (three fetches per open), while a preloaded
|
// prev/next neighbours (three fetches per open), while a preloaded
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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<Buffer|null>} 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,
|
||||||
|
};
|
||||||
@@ -24,6 +24,8 @@ const watermarkService = require('./watermarkService');
|
|||||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
|
||||||
|
const { renderPhotoForDownload } = require('./downloadRendition');
|
||||||
|
const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const DEBOUNCE_MS = 5000;
|
const DEBOUNCE_MS = 5000;
|
||||||
@@ -137,6 +139,12 @@ class DownloadZipService {
|
|||||||
text: event.watermark_text || watermarkSettings?.text || 'Protected',
|
text: event.watermark_text || watermarkSettings?.text || 'Protected',
|
||||||
} : null;
|
} : 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);
|
const finalKey = this.getCacheKey(event.slug);
|
||||||
|
|
||||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
|
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.
|
// for external, in which case fall back to resolvePhotoFilePath.
|
||||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||||
|
|
||||||
if (shouldApplyWatermark && effectiveSettings) {
|
// Resize to the gallery's standard resolution (#858) and/or
|
||||||
try {
|
// watermark. Returns null when neither applies, so an
|
||||||
let sourcePath;
|
// original-size unwatermarked gallery still streams straight
|
||||||
if (storageKey) {
|
// from storage with nothing buffered.
|
||||||
// Stream the original to a tmp file just long enough for sharp
|
let rendered = null;
|
||||||
// (watermarkService) to operate on it. Avoids buffering the
|
try {
|
||||||
// entire image in memory for huge originals.
|
rendered = await renderPhotoForDownload(event, photo, standardBox, effectiveSettings);
|
||||||
sourcePath = path.join(tmpDir, `wm-${crypto.randomBytes(4).toString('hex')}`);
|
} catch (err) {
|
||||||
await storage.getToFile(storageKey, sourcePath);
|
logger.warn('Skipping photo in pre-zip', { photoId: photo.id, error: err.message });
|
||||||
} else {
|
continue;
|
||||||
sourcePath = resolvePhotoFilePath(event, photo);
|
}
|
||||||
}
|
|
||||||
const buf = await watermarkService.applyWatermark(sourcePath, effectiveSettings);
|
if (rendered) {
|
||||||
archive.append(buf, { name: archiveName });
|
archive.append(rendered, { name: archiveName });
|
||||||
if (storageKey) {
|
|
||||||
await fsp.unlink(sourcePath).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Skipping watermark in pre-zip', { photoId: photo.id, error: err.message });
|
|
||||||
}
|
|
||||||
} else if (storageKey) {
|
} else if (storageKey) {
|
||||||
const stream = await storage.get(storageKey);
|
const stream = await storage.get(storageKey);
|
||||||
archive.append(stream, { name: archiveName });
|
archive.append(stream, { name: archiveName });
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
|
resizeToBox,
|
||||||
generateThumbnail,
|
generateThumbnail,
|
||||||
isThumbnailValid,
|
isThumbnailValid,
|
||||||
ensureThumbnail,
|
ensureThumbnail,
|
||||||
|
|||||||
@@ -90,18 +90,29 @@ class WatermarkService {
|
|||||||
/**
|
/**
|
||||||
* Apply watermark to an image
|
* 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) {
|
async applyWatermark(imagePath, settings) {
|
||||||
|
const isBuffer = Buffer.isBuffer(imagePath);
|
||||||
try {
|
try {
|
||||||
if (!settings || !settings.enabled) {
|
if (!settings || !settings.enabled) {
|
||||||
// Return original image if watermarking is disabled
|
// Return original image if watermarking is disabled
|
||||||
return await fs.readFile(imagePath);
|
return isBuffer ? imagePath : await fs.readFile(imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first. Buffer inputs are already-resized intermediates:
|
||||||
const cacheKey = `${imagePath}_${JSON.stringify(settings)}`;
|
// they have no stable key (hashing megabytes per photo would cost more
|
||||||
const cached = this.cache.get(cacheKey);
|
// than the watermark) and no reuse across requests, so skip the cache.
|
||||||
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
|
const cacheKey = isBuffer ? null : `${imagePath}_${JSON.stringify(settings)}`;
|
||||||
return cached.buffer;
|
if (cacheKey) {
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) {
|
||||||
|
return cached.buffer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the main image
|
// Load the main image
|
||||||
@@ -199,20 +210,24 @@ class WatermarkService {
|
|||||||
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
|
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result (path inputs only — see cacheKey above)
|
||||||
this.cache.set(cacheKey, {
|
if (cacheKey) {
|
||||||
buffer: watermarkedBuffer,
|
this.cache.set(cacheKey, {
|
||||||
timestamp: Date.now()
|
buffer: watermarkedBuffer,
|
||||||
});
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
// Clean old cache entries
|
// Clean old cache entries
|
||||||
this.cleanCache();
|
this.cleanCache();
|
||||||
|
}
|
||||||
|
|
||||||
return watermarkedBuffer;
|
return watermarkedBuffer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error applying watermark:', error);
|
logger.error('Error applying watermark:', error);
|
||||||
// Return original image on error
|
// Return the un-watermarked input on error. Buffer inputs are already
|
||||||
return await fs.readFile(imagePath);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* <DownloadResolutionCard>
|
||||||
|
*
|
||||||
|
* 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<DownloadResolutionCardProps> = ({ eventId, onChanged }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [standard, setStandard] = useState<string>(INHERIT);
|
||||||
|
const [picker, setPicker] = useState<string>(INHERIT);
|
||||||
|
const [allowOriginal, setAllowOriginal] = useState<string>(INHERIT);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery<Payload>({
|
||||||
|
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 <Card><Loading /></Card>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Download className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.downloads.eventTitle', 'Download resolution')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
{t('settings.downloads.eventIntro',
|
||||||
|
'Override the site-wide download settings for this gallery only. "Inherit" follows Settings → Download resolutions.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.downloads.standard', 'Standard resolution')}
|
||||||
|
</label>
|
||||||
|
<select className={selectClass} value={standard} onChange={(e) => setStandard(e.target.value)}>
|
||||||
|
<option value={INHERIT}>
|
||||||
|
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', { value: globalStandardLabel })}
|
||||||
|
</option>
|
||||||
|
<option value={ORIGINAL}>{t('settings.downloads.original', 'Original (full size)')}</option>
|
||||||
|
{data.globals.resolutions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.label} — {r.width} × {r.height}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.downloads.picker', 'Let guests choose a download size')}
|
||||||
|
</label>
|
||||||
|
<select className={selectClass} value={picker} onChange={(e) => setPicker(e.target.value)}>
|
||||||
|
<option value={INHERIT}>
|
||||||
|
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', {
|
||||||
|
value: data.globals.picker_enabled ? t('common.on', 'on') : t('common.off', 'off'),
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
<option value="true">{t('common.on', 'on')}</option>
|
||||||
|
<option value="false">{t('common.off', 'off')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.downloads.allowOriginal', 'Offer "Original" in the picker')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={allowOriginal}
|
||||||
|
onChange={(e) => setAllowOriginal(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value={INHERIT}>
|
||||||
|
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', {
|
||||||
|
value: data.globals.allow_original ? t('common.on', 'on') : t('common.off', 'off'),
|
||||||
|
})}
|
||||||
|
</option>
|
||||||
|
<option value="true">{t('common.on', 'on')}</option>
|
||||||
|
<option value="false">{t('common.off', 'off')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What the gallery actually does right now, after the cascade. */}
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
|
||||||
|
{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')}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<Button variant="primary" onClick={save} disabled={saving} leftIcon={<Save className="w-4 h-4" />}>
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<DownloadResolutionModalProps> = ({
|
||||||
|
slug,
|
||||||
|
choices,
|
||||||
|
standardResolution,
|
||||||
|
photoIds,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [phase, setPhase] = useState<Phase>('choose');
|
||||||
|
const [selected, setSelected] = useState<string>(choices[0]?.id ?? 'original');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [photoCount, setPhotoCount] = useState(0);
|
||||||
|
const tokenRef = useRef<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('gallery.chooseResolution', 'Choose a download size')}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="max-w-md w-full"
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('gallery.chooseResolution', 'Choose a download size')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('common.close', 'Close')}
|
||||||
|
className="text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === 'choose' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 mb-5">
|
||||||
|
{choices.map((choice) => (
|
||||||
|
<label
|
||||||
|
key={choice.id}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selected === choice.id
|
||||||
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
|
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="download-resolution"
|
||||||
|
value={choice.id}
|
||||||
|
checked={selected === choice.id}
|
||||||
|
onChange={() => setSelected(choice.id)}
|
||||||
|
className="accent-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{choice.label}
|
||||||
|
</span>
|
||||||
|
{choice.width && choice.height && (
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('gallery.resolutionUpTo', 'up to {{width}} × {{height}} px', {
|
||||||
|
width: choice.width,
|
||||||
|
height: choice.height,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={start} leftIcon={<Download className="w-4 h-4" />}>
|
||||||
|
{t('gallery.prepareDownload', 'Prepare download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'preparing' && (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-primary-600" />
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('gallery.preparingDownload', 'Preparing your download…')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
|
{photoCount > 0
|
||||||
|
? t('gallery.preparingProgress', '{{count}} photos packaged', { count: photoCount })
|
||||||
|
: t('gallery.preparingHint', 'Resizing photos — this can take a moment for large galleries.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'ready' && (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<Check className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-4">
|
||||||
|
{t('gallery.downloadReady', 'Your download is ready')}
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" onClick={download} leftIcon={<Download className="w-4 h-4" />}>
|
||||||
|
{t('gallery.downloadNow', 'Download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'error' && (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-red-600 dark:text-red-400" />
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300 mb-4">{error}</p>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setPhase('choose')}>
|
||||||
|
{t('common.retry', 'Try again')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { GallerySkeleton } from './GallerySkeleton';
|
|||||||
import { useGalleryAuth, useTheme } from '../../contexts';
|
import { useGalleryAuth, useTheme } from '../../contexts';
|
||||||
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
||||||
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
|
||||||
|
import { DownloadResolutionModal } from './DownloadResolutionModal';
|
||||||
import { ExpirationBanner } from './ExpirationBanner';
|
import { ExpirationBanner } from './ExpirationBanner';
|
||||||
import { CountdownTimer } from './CountdownTimer';
|
import { CountdownTimer } from './CountdownTimer';
|
||||||
import { GalleryLayout } from './GalleryLayout';
|
import { GalleryLayout } from './GalleryLayout';
|
||||||
@@ -73,6 +74,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(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<number[] | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
const [sortDesc, setSortDesc] = useState(true);
|
||||||
@@ -607,12 +612,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
// Check if downloads are allowed (both event setting and not expired)
|
// Check if downloads are allowed (both event setting and not expired)
|
||||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
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 = () => {
|
const handleDownloadAll = () => {
|
||||||
// Prevent downloads if gallery is expired or downloads disabled
|
// Prevent downloads if gallery is expired or downloads disabled
|
||||||
if (!allowDownloads) {
|
if (!allowDownloads) {
|
||||||
return;
|
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 });
|
downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready });
|
||||||
|
|
||||||
// Track download all action
|
// Track download all action
|
||||||
@@ -631,6 +649,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
return;
|
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));
|
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
|
||||||
|
|
||||||
// Track bulk download
|
// Track bulk download
|
||||||
@@ -771,6 +797,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onClose={() => setShowUploadModal(false)}
|
onClose={() => setShowUploadModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Download size picker (#858) — "download all", or a selection. */}
|
||||||
|
{(showResolutionPicker || resolutionPickerIds) && (
|
||||||
|
<DownloadResolutionModal
|
||||||
|
slug={slug}
|
||||||
|
choices={downloadChoices}
|
||||||
|
standardResolution={data?.event?.download_resolution?.standard}
|
||||||
|
photoIds={resolutionPickerIds || undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setShowResolutionPicker(false);
|
||||||
|
setResolutionPickerIds(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</GalleryLayout>
|
</GalleryLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -814,6 +854,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
eventDate={event.event_date}
|
eventDate={event.event_date}
|
||||||
expiresAt={event.expires_at}
|
expiresAt={event.expires_at}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
downloadChoices={downloadChoices}
|
||||||
|
downloadStandard={data?.event?.download_resolution?.standard}
|
||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={protectionLevel !== 'basic'}
|
useEnhancedProtection={protectionLevel !== 'basic'}
|
||||||
disableRightClick={disableRightClick}
|
disableRightClick={disableRightClick}
|
||||||
@@ -842,6 +884,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onClose={() => setShowUploadModal(false)}
|
onClose={() => setShowUploadModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Download size picker (#858) — "download all", or a selection. */}
|
||||||
|
{(showResolutionPicker || resolutionPickerIds) && (
|
||||||
|
<DownloadResolutionModal
|
||||||
|
slug={slug}
|
||||||
|
choices={downloadChoices}
|
||||||
|
standardResolution={data?.event?.download_resolution?.standard}
|
||||||
|
photoIds={resolutionPickerIds || undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setShowResolutionPicker(false);
|
||||||
|
setResolutionPickerIds(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1071,6 +1127,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
eventDate={event.event_date}
|
eventDate={event.event_date}
|
||||||
expiresAt={event.expires_at}
|
expiresAt={event.expires_at}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
downloadChoices={downloadChoices}
|
||||||
|
downloadStandard={data?.event?.download_resolution?.standard}
|
||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={protectionLevel !== 'basic'}
|
useEnhancedProtection={protectionLevel !== 'basic'}
|
||||||
disableRightClick={disableRightClick}
|
disableRightClick={disableRightClick}
|
||||||
@@ -1102,6 +1160,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onClose={() => setShowUploadModal(false)}
|
onClose={() => setShowUploadModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Download size picker (#858) — "download all", or a selection. */}
|
||||||
|
{(showResolutionPicker || resolutionPickerIds) && (
|
||||||
|
<DownloadResolutionModal
|
||||||
|
slug={slug}
|
||||||
|
choices={downloadChoices}
|
||||||
|
standardResolution={data?.event?.download_resolution?.standard}
|
||||||
|
photoIds={resolutionPickerIds || undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setShowResolutionPicker(false);
|
||||||
|
setResolutionPickerIds(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</GalleryLayout>
|
</GalleryLayout>
|
||||||
</>
|
</>
|
||||||
</GuestIdentityProvider>
|
</GuestIdentityProvider>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Package } from 'lucide-react';
|
|||||||
import { toast as toastify } from 'react-toastify';
|
import { toast as toastify } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { Photo } from '../../types';
|
import type { Photo, DownloadResolutionChoice } from '../../types';
|
||||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||||
import { PhotoLightbox } from './PhotoLightbox';
|
import { PhotoLightbox } from './PhotoLightbox';
|
||||||
|
import { DownloadResolutionModal } from './DownloadResolutionModal';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { analyticsService } from '../../services/analytics.service';
|
import { analyticsService } from '../../services/analytics.service';
|
||||||
@@ -42,6 +43,10 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
allowDownloads?: 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';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
useCanvasRendering?: boolean;
|
useCanvasRendering?: boolean;
|
||||||
@@ -87,6 +92,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
onFeedbackChange,
|
onFeedbackChange,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
|
downloadChoices,
|
||||||
|
downloadStandard,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
useCanvasRendering = false,
|
useCanvasRendering = false,
|
||||||
@@ -117,6 +124,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
const [openFeedbackInitially, setOpenFeedbackInitially] = useState<boolean>(false);
|
const [openFeedbackInitially, setOpenFeedbackInitially] = useState<boolean>(false);
|
||||||
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||||
|
// Non-null while the resolution picker is open (#858); holds the ids it applies to.
|
||||||
|
const [resolutionPickerIds, setResolutionPickerIds] = useState<number[] | null>(null);
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
|
|
||||||
// Use parent state if provided, otherwise use local state
|
// Use parent state if provided, otherwise use local state
|
||||||
@@ -183,6 +192,14 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
const handleDownloadSelected = async () => {
|
const handleDownloadSelected = async () => {
|
||||||
if (selectedPhotos.size === 0) return;
|
if (selectedPhotos.size === 0) return;
|
||||||
const ids = Array.from(selectedPhotos);
|
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 }));
|
toastify.info(t('gallery.downloading', { count: ids.length }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +232,11 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
const layoutProps = {
|
const layoutProps = {
|
||||||
photos,
|
photos,
|
||||||
slug,
|
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,
|
onPhotoClick: handlePhotoClick,
|
||||||
onOpenPhotoWithFeedback: handleOpenWithFeedback,
|
onOpenPhotoWithFeedback: handleOpenWithFeedback,
|
||||||
onFeedbackChange: onFeedbackChange,
|
onFeedbackChange: onFeedbackChange,
|
||||||
@@ -387,6 +409,25 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
showOriginalFilename={showOriginalFilename}
|
showOriginalFilename={showOriginalFilename}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Download size picker (#858) — drives the job build and the download. */}
|
||||||
|
{resolutionPickerIds && downloadChoices && (
|
||||||
|
<DownloadResolutionModal
|
||||||
|
slug={slug}
|
||||||
|
choices={downloadChoices}
|
||||||
|
standardResolution={downloadStandard}
|
||||||
|
photoIds={resolutionPickerIds}
|
||||||
|
onClose={() => {
|
||||||
|
setResolutionPickerIds(null);
|
||||||
|
setSelectedPhotos(new Set());
|
||||||
|
if (parentToggleSelectionMode) {
|
||||||
|
parentToggleSelectionMode();
|
||||||
|
} else {
|
||||||
|
setLocalSelectionMode(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Photo } from '../../../types';
|
import type { Photo, DownloadResolutionChoice } from '../../../types';
|
||||||
|
|
||||||
export interface BaseGalleryLayoutProps {
|
export interface BaseGalleryLayoutProps {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
@@ -20,6 +20,11 @@ export interface BaseGalleryLayoutProps {
|
|||||||
eventDate?: string | null;
|
eventDate?: string | null;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
allowDownloads?: boolean;
|
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';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
useCanvasRendering?: boolean;
|
useCanvasRendering?: boolean;
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
eventName,
|
eventName,
|
||||||
eventDate,
|
eventDate,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
|
downloadChoices,
|
||||||
|
onPickResolution,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
useCanvasRendering = false,
|
useCanvasRendering = false,
|
||||||
@@ -398,6 +400,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
const handleDownloadSelected = useCallback(async () => {
|
const handleDownloadSelected = useCallback(async () => {
|
||||||
if (selectedPhotos.size === 0) return;
|
if (selectedPhotos.size === 0) return;
|
||||||
const ids = Array.from(selectedPhotos);
|
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 }));
|
toast.info(t('gallery.downloading', { count: ids.length }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -406,7 +413,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error(t('gallery.downloadError'));
|
toast.error(t('gallery.downloadError'));
|
||||||
}
|
}
|
||||||
}, [selectedPhotos, slug, t]);
|
}, [selectedPhotos, slug, t, downloadChoices, onPickResolution]);
|
||||||
|
|
||||||
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
|
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
|
||||||
if (!allowDownloads || !slide.src) return;
|
if (!allowDownloads || !slide.src) return;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
eventName,
|
eventName,
|
||||||
eventDate,
|
eventDate,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
|
downloadChoices,
|
||||||
|
onPickResolution,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
useCanvasRendering = false,
|
useCanvasRendering = false,
|
||||||
@@ -232,15 +234,20 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
}, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]);
|
}, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]);
|
||||||
|
|
||||||
const handleDownloadAll = useCallback(async () => {
|
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 }));
|
toast.info(t('gallery.downloading', { count: photos.length }));
|
||||||
try {
|
try {
|
||||||
const ids = photos.map(p => p.id);
|
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('gallery.downloadError'));
|
toast.error(t('gallery.downloadError'));
|
||||||
}
|
}
|
||||||
}, [photos, slug, t]);
|
}, [photos, slug, t, downloadChoices, onPickResolution]);
|
||||||
|
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { ModerationTab } from './tabs/ModerationTab';
|
|||||||
export { StylingTab } from './tabs/StylingTab';
|
export { StylingTab } from './tabs/StylingTab';
|
||||||
export { SEOTab } from './tabs/SEOTab';
|
export { SEOTab } from './tabs/SEOTab';
|
||||||
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
||||||
|
export { DownloadsTab } from './tabs/DownloadsTab';
|
||||||
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
export { ApiTokensTab } from './tabs/ApiTokensTab';
|
||||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||||
export { AccountingTab } from './tabs/AccountingTab';
|
export { AccountingTab } from './tabs/AccountingTab';
|
||||||
|
|||||||
@@ -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<DownloadSettings | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<DownloadSettings>({
|
||||||
|
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 <Loading />;
|
||||||
|
|
||||||
|
const setPreset = (i: number, patch: Partial<Preset>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Download className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.downloads.title', 'Download resolutions')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-5">
|
||||||
|
{t('settings.downloads.intro',
|
||||||
|
'The standard size is what every gallery hands out by default. Individual galleries can override this.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.downloads.standard', 'Standard resolution')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className={`mb-5 ${selectClass}`}
|
||||||
|
value={form.standard_resolution}
|
||||||
|
onChange={(e) => setForm({ ...form, standard_resolution: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value={ORIGINAL}>{t('settings.downloads.original', 'Original (full size)')}</option>
|
||||||
|
{form.resolutions.map((r) => (
|
||||||
|
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
|
||||||
|
{r.label} — {r.width} × {r.height}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 mb-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 accent-primary-600"
|
||||||
|
checked={form.picker_enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, picker_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.downloads.picker', 'Let guests choose a download size')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.downloads.pickerHint',
|
||||||
|
'Adds a size picker to bulk downloads. Custom sizes are prepared on demand and are never larger than the standard.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 mb-5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 accent-primary-600"
|
||||||
|
checked={form.allow_original}
|
||||||
|
disabled={!form.picker_enabled}
|
||||||
|
onChange={(e) => setForm({ ...form, allow_original: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.downloads.allowOriginal', 'Offer "Original" in the picker')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.downloads.allowOriginalHint',
|
||||||
|
'Off by default: lowering the standard size normally means full-resolution files should not be handed out.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.downloads.presets', 'Available sizes')}
|
||||||
|
</h3>
|
||||||
|
<Button variant="outline" size="sm" onClick={addPreset} leftIcon={<Plus className="w-4 h-4" />}>
|
||||||
|
{t('common.add', 'Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
{t('settings.downloads.presetsHint',
|
||||||
|
'Sizes are an upper bound — the aspect ratio is kept and photos are never enlarged.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.resolutions.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={r.label}
|
||||||
|
onChange={(e) => setPreset(i, { label: e.target.value })}
|
||||||
|
className="flex-1"
|
||||||
|
aria-label={t('settings.downloads.label', 'Label')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={String(r.width)}
|
||||||
|
onChange={(e) => setPreset(i, { width: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
className="w-28"
|
||||||
|
aria-label={t('settings.downloads.width', 'Width')}
|
||||||
|
/>
|
||||||
|
<span className="text-neutral-400">×</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={String(r.height)}
|
||||||
|
onChange={(e) => setPreset(i, { height: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
className="w-28"
|
||||||
|
aria-label={t('settings.downloads.height', 'Height')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePreset(i)}
|
||||||
|
disabled={form.resolutions.length <= 1}
|
||||||
|
aria-label={t('common.remove', 'Remove')}
|
||||||
|
className="p-2 text-neutral-500 hover:text-red-600 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => save.mutate(form)}
|
||||||
|
disabled={save.isPending}
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -153,7 +153,9 @@
|
|||||||
"showAll": "Alle anzeigen",
|
"showAll": "Alle anzeigen",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"show": "Einblenden",
|
"show": "Einblenden",
|
||||||
"poweredBy": "Bereitgestellt von"
|
"poweredBy": "Bereitgestellt von",
|
||||||
|
"on": "an",
|
||||||
|
"off": "aus"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Fotokategorie",
|
"photoCategory": "Fotokategorie",
|
||||||
@@ -1044,7 +1046,17 @@
|
|||||||
"socials": "Soziale Netzwerke"
|
"socials": "Soziale Netzwerke"
|
||||||
},
|
},
|
||||||
"photosCount_one": "{{count}} Foto",
|
"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": {
|
"categories": {
|
||||||
"title": "Fotokategorien",
|
"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.",
|
"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“)"
|
"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": {
|
"branding": {
|
||||||
|
|||||||
@@ -153,7 +153,9 @@
|
|||||||
"showAll": "Show all",
|
"showAll": "Show all",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"poweredBy": "Powered by"
|
"poweredBy": "Powered by",
|
||||||
|
"on": "on",
|
||||||
|
"off": "off"
|
||||||
},
|
},
|
||||||
"upload": {
|
"upload": {
|
||||||
"photoCategory": "Photo Category",
|
"photoCategory": "Photo Category",
|
||||||
@@ -589,7 +591,17 @@
|
|||||||
"photosSelected_one": "{{count}} photo selected",
|
"photosSelected_one": "{{count}} photo selected",
|
||||||
"photosSelected_other": "{{count}} photos selected",
|
"photosSelected_other": "{{count}} photos selected",
|
||||||
"downloadSelected_one": "Download {{count}} photo",
|
"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": {
|
"categories": {
|
||||||
"title": "Photo 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.",
|
"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\")"
|
"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": {
|
"analytics": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Search,
|
Search,
|
||||||
Tags,
|
Tags,
|
||||||
|
Download as DownloadIcon,
|
||||||
Tag,
|
Tag,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Flag,
|
Flag,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
StylingTab,
|
StylingTab,
|
||||||
SEOTab,
|
SEOTab,
|
||||||
ThumbnailsTab,
|
ThumbnailsTab,
|
||||||
|
DownloadsTab,
|
||||||
ApiTokensTab,
|
ApiTokensTab,
|
||||||
WebhooksTab,
|
WebhooksTab,
|
||||||
AccountingTab,
|
AccountingTab,
|
||||||
@@ -68,6 +70,7 @@ type TabType =
|
|||||||
| 'branding'
|
| 'branding'
|
||||||
| 'categories'
|
| 'categories'
|
||||||
| 'thumbnails'
|
| 'thumbnails'
|
||||||
|
| 'downloads'
|
||||||
| 'styling'
|
| 'styling'
|
||||||
| 'cms'
|
| 'cms'
|
||||||
| 'email'
|
| 'email'
|
||||||
@@ -104,7 +107,7 @@ interface NavGroup {
|
|||||||
|
|
||||||
const ALL_TAB_KEYS: TabType[] = [
|
const ALL_TAB_KEYS: TabType[] = [
|
||||||
'features', 'general', 'events', 'eventTypes',
|
'features', 'general', 'events', 'eventTypes',
|
||||||
'branding', 'categories', 'thumbnails', 'styling', 'cms',
|
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
|
||||||
'email', 'moderation',
|
'email', 'moderation',
|
||||||
'security', 'sso', 'imageSecurity', 'seo',
|
'security', 'sso', 'imageSecurity', 'seo',
|
||||||
'apiTokens', 'webhooks',
|
'apiTokens', 'webhooks',
|
||||||
@@ -244,6 +247,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{ key: 'branding', label: t('settings.branding.title', 'Branding'), icon: Palette },
|
{ key: 'branding', label: t('settings.branding.title', 'Branding'), icon: Palette },
|
||||||
{ key: 'categories', label: t('settings.categories.title'), icon: Tags },
|
{ key: 'categories', label: t('settings.categories.title'), icon: Tags },
|
||||||
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon },
|
{ 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: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code },
|
||||||
{ key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText },
|
{ key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText },
|
||||||
...(flags.slideshow
|
...(flags.slideshow
|
||||||
@@ -495,6 +499,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
||||||
{activeTab === 'thumbnails' && <ThumbnailsTab />}
|
{activeTab === 'thumbnails' && <ThumbnailsTab />}
|
||||||
|
{activeTab === 'downloads' && <DownloadsTab />}
|
||||||
{activeTab === 'categories' && <CategoriesTab />}
|
{activeTab === 'categories' && <CategoriesTab />}
|
||||||
|
|
||||||
{activeTab === 'analytics' && (
|
{activeTab === 'analytics' && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Event } from '../../../types';
|
|||||||
import { FeedbackModerationPanel } from '../../../components/admin';
|
import { FeedbackModerationPanel } from '../../../components/admin';
|
||||||
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
|
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
|
||||||
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
|
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
|
||||||
|
import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard';
|
||||||
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
|
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
|
||||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||||
import type { AdminPhoto } from '../../../services/photos.service';
|
import type { AdminPhoto } from '../../../services/photos.service';
|
||||||
@@ -116,6 +117,10 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
|||||||
{/* Client Access (#172) */}
|
{/* Client Access (#172) */}
|
||||||
<ClientAccessCard event={event} refetchEvent={refetchEvent} />
|
<ClientAccessCard event={event} refetchEvent={refetchEvent} />
|
||||||
|
|
||||||
|
{/* Per-gallery download resolution override (#858). Sits with the
|
||||||
|
other "what the customer receives" controls. */}
|
||||||
|
<DownloadResolutionCard eventId={event.id} onChanged={() => refetchEvent()} />
|
||||||
|
|
||||||
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
|
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
|
||||||
Gated behind the `slideshow` feature flag. */}
|
Gated behind the `slideshow` feature flag. */}
|
||||||
{flags.slideshow && (
|
{flags.slideshow && (
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { api } from '../config/api';
|
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 { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||||
|
|
||||||
@@ -280,6 +283,39 @@ export const galleryService = {
|
|||||||
window.URL.revokeObjectURL(url);
|
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<string, unknown> = { 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<DownloadJobState> {
|
||||||
|
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.
|
// iOS-only Web Share path for a selection of photos.
|
||||||
//
|
//
|
||||||
// Returns:
|
// Returns:
|
||||||
|
|||||||
@@ -173,6 +173,24 @@ export interface Photo {
|
|||||||
favorite_count?: number;
|
favorite_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download resolutions (#858).
|
||||||
|
export type DownloadJobStatus = 'pending' | 'building' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
export interface DownloadResolutionChoice {
|
||||||
|
id: string; // 'original' | '<width>x<height>'
|
||||||
|
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 {
|
export interface PhotoCategory {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -187,6 +205,13 @@ export interface GalleryData {
|
|||||||
event_name: string;
|
event_name: string;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_date: string | null;
|
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;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user