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:
+6
-1
@@ -161,7 +161,12 @@ const corsOptions = {
|
|||||||
callback(null, false);
|
callback(null, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
credentials: true
|
credentials: true,
|
||||||
|
// Expose Content-Disposition so split (cross-origin) frontend
|
||||||
|
// deployments can read the server's chosen download filename. Used
|
||||||
|
// by the gallery/admin download flows to honour the #493 "original
|
||||||
|
// camera filename" toggle on individual photo downloads (#507).
|
||||||
|
exposedHeaders: ['Content-Disposition'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only attach CORS to API endpoints, not static assets
|
// Only attach CORS to API endpoints, not static assets
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||||
const { withLocalCopy } = require('../services/imageProcessor');
|
const { withLocalCopy } = require('../services/imageProcessor');
|
||||||
const { getStorage } = require('../services/storage');
|
const { getStorage } = require('../services/storage');
|
||||||
|
const {
|
||||||
|
getUseOriginalFilenames,
|
||||||
|
pickRawDownloadName,
|
||||||
|
} = require('../services/downloadFilenameService');
|
||||||
|
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
'download'
|
'download'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// #493/#507: respect the original-filename toggle here too. The
|
||||||
|
// regular `/gallery/:slug/download/:photoId` route already does
|
||||||
|
// this — secure-images was missed in the original PR and ran
|
||||||
|
// even when the admin had opted into original camera filenames.
|
||||||
|
const useOriginal = await getUseOriginalFilenames();
|
||||||
|
const downloadName = pickRawDownloadName(photo, useOriginal);
|
||||||
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
'Content-Disposition': buildContentDisposition(downloadName),
|
||||||
'Content-Length': fileBuffer.length,
|
'Content-Length': fileBuffer.length,
|
||||||
'X-Download-Protected': 'true'
|
'X-Download-Protected': 'true'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
@@ -49,35 +50,38 @@ export const galleryService = {
|
|||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||||
try {
|
// Honour the server's Content-Disposition filename so the #493
|
||||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
// "use original camera filename" toggle reaches disk for single
|
||||||
responseType: 'blob',
|
// 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 url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', serverFilename || filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
};
|
||||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
downloadFromResponse(response);
|
||||||
const link = document.createElement('a');
|
} catch {
|
||||||
link.href = url;
|
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
|
||||||
link.setAttribute('download', filename);
|
// The view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||||
document.body.appendChild(link);
|
// so we expect the caller-supplied filename to win here.
|
||||||
link.click();
|
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||||
link.remove();
|
responseType: 'blob',
|
||||||
window.URL.revokeObjectURL(url);
|
});
|
||||||
} catch (fallbackErr) {
|
downloadFromResponse(response);
|
||||||
throw fallbackErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
|
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||||
|
|
||||||
export interface AdminPhoto {
|
export interface AdminPhoto {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -102,11 +103,18 @@ class PhotosService {
|
|||||||
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||||
responseType: 'blob'
|
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 url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.download = serverFilename || filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Parse the `filename` out of a `Content-Disposition` response header.
|
||||||
|
*
|
||||||
|
* Prefers the RFC 5987 form (`filename*=UTF-8''<percent-encoded>`) so unicode
|
||||||
|
* camera filenames round-trip correctly, and falls back to the plain
|
||||||
|
* `filename="..."` token. Returns null when the header is missing or
|
||||||
|
* unparseable so the caller can choose its own fallback (typically the
|
||||||
|
* client-side photo.filename).
|
||||||
|
*
|
||||||
|
* Why this exists: backend download routes emit a Content-Disposition with
|
||||||
|
* the user-facing filename (which may be the original camera name when the
|
||||||
|
* #493 toggle is on). The frontend used to override that with a hardcoded
|
||||||
|
* `<a download="X">` attribute, so the server's filename never reached
|
||||||
|
* disk. Reading it back from the response means the server stays the
|
||||||
|
* single source of truth.
|
||||||
|
*/
|
||||||
|
export function parseContentDispositionFilename(header: string | null | undefined): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
|
||||||
|
// RFC 5987: filename*=UTF-8''<percent-encoded-bytes>
|
||||||
|
// The optional language tag (e.g. UTF-8'en'foo.jpg) is rarely emitted by
|
||||||
|
// servers; we accept the empty case only since that's what we produce.
|
||||||
|
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
|
||||||
|
if (star && star[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(star[1].trim());
|
||||||
|
} catch {
|
||||||
|
// Malformed percent-encoding — fall through to the plain form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain quoted form: filename="..."
|
||||||
|
const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header);
|
||||||
|
if (quoted && quoted[1]) {
|
||||||
|
return quoted[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain unquoted form: filename=... (terminated by ; or end-of-string)
|
||||||
|
const unquoted = /filename\s*=\s*([^;]+)/i.exec(header);
|
||||||
|
if (unquoted && unquoted[1]) {
|
||||||
|
return unquoted[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user