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

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

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

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

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

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

Closes #858.
This commit is contained in:
Paul Nothaft
2026-08-11 09:46:46 +02:00
committed by GitHub
parent 02deac9f10
commit 8e3573788b
29 changed files with 2716 additions and 82 deletions
@@ -0,0 +1,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>
);
};