fix(downloads): apply original-filename toggle to individual downloads too (#507)
Follow-up to #498. The toggle reached zip downloads but single-photo downloads still landed on disk with the renamed `event_individual_NNN.jpg` even when the admin had flipped the setting on. Two reasons, fixed in lockstep: - Frontend overrode the server's Content-Disposition with a hardcoded `<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`) where X was the sanitized `photo.filename` known to the client. So the backend's correctly-formed `Content-Disposition` never reached the disk write. Added `parseContentDispositionFilename` (RFC 5987 + plain `filename=` fallback) and let the server name win when present. - `secureImages.js` (enhanced/maximum protection's secure-download route) was missed in #498 and still emitted a hardcoded `filename="${photo.filename}"` regardless of the toggle. Wired it through `getUseOriginalFilenames` + `buildContentDisposition` so it matches the regular gallery download path. Also exposed `Content-Disposition` via CORS so split (cross-origin) frontend deployments can still read it from JavaScript. Same-origin Docker deploys already had access; this is a defensive addition for the split case.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||
|
||||
export const galleryService = {
|
||||
// Verify share token
|
||||
@@ -49,35 +50,38 @@ export const galleryService = {
|
||||
|
||||
// Download single photo
|
||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
// Honour the server's Content-Disposition filename so the #493
|
||||
// "use original camera filename" toggle reaches disk for single
|
||||
// downloads (it already worked for zips because those skip the
|
||||
// `<a download>` attribute). Falls back to the caller-provided
|
||||
// sanitized filename if the header is unreadable.
|
||||
const downloadFromResponse = (response: { data: Blob; headers: Record<string, string> }) => {
|
||||
const headerName =
|
||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||
const serverFilename = parseContentDispositionFilename(headerName);
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
link.setAttribute('download', serverFilename || filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (fallbackErr) {
|
||||
throw fallbackErr;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
downloadFromResponse(response);
|
||||
} catch {
|
||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
|
||||
// The view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||
// so we expect the caller-supplied filename to win here.
|
||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
downloadFromResponse(response);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||
|
||||
export interface AdminPhoto {
|
||||
id: number;
|
||||
@@ -102,11 +103,18 @@ class PhotosService {
|
||||
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
|
||||
// Read the filename from the server's Content-Disposition so the
|
||||
// #493 original-filename toggle reaches disk for admin downloads
|
||||
// too (see contentDisposition.ts).
|
||||
const headerName =
|
||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||
const serverFilename = parseContentDispositionFilename(headerName);
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.download = serverFilename || filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
Reference in New Issue
Block a user