New Settings → General toggle `Use original filenames on download` (off by default). When on, single-photo downloads, bulk/selection zips, and per-event archive zips surface `photos.original_filename` instead of the sanitized storage filename. Storage paths are unchanged. - Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`) so unicode camera filenames survive while header-injection bytes are stripped. - Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on collision (folder structure preserved in archive zips). - Pre-generated download-all zips and the in-memory setting cache are invalidated when the toggle flips so the next download rebuilds with the new names. - Falls back to the storage filename whenever `original_filename` is null (legacy uploads predating migration 062).
129 lines
4.3 KiB
JavaScript
129 lines
4.3 KiB
JavaScript
/**
|
|
* Tests for the download-filename helpers added in #493.
|
|
* Covers header-injection defence, RFC 5987 encoding, zip path-traversal
|
|
* sanitization, and deterministic collision suffixing.
|
|
*/
|
|
|
|
const {
|
|
sanitizeForContentDisposition,
|
|
buildContentDisposition,
|
|
sanitizeForZipEntry,
|
|
uniquifyZipNames,
|
|
} = require('../../src/utils/filenameSanitizer');
|
|
|
|
describe('sanitizeForContentDisposition', () => {
|
|
it('keeps ASCII camera filenames intact', () => {
|
|
expect(sanitizeForContentDisposition('DSC_1234.jpg')).toBe('DSC_1234.jpg');
|
|
});
|
|
|
|
it('strips CR/LF/NUL to defeat header injection', () => {
|
|
const out = sanitizeForContentDisposition('file\r\nX-Evil: yes\nname.jpg');
|
|
expect(out).not.toMatch(/[\r\n\0]/);
|
|
expect(out).toContain('X-Evil');
|
|
expect(out).toContain('name.jpg');
|
|
});
|
|
|
|
it('replaces path separators and quotes with underscore', () => {
|
|
// Leading dots get stripped to defeat `..` showing up as a download.
|
|
expect(sanitizeForContentDisposition('../etc/passwd')).toBe('etc_passwd');
|
|
expect(sanitizeForContentDisposition('a"b.jpg')).toBe('a_b.jpg');
|
|
});
|
|
|
|
it('collapses non-ASCII bytes (filename* carries them instead)', () => {
|
|
const out = sanitizeForContentDisposition('über_照片.jpg');
|
|
expect(out).toMatch(/^[\x20-\x7E]+$/);
|
|
expect(out).toContain('.jpg');
|
|
});
|
|
|
|
it('returns a safe fallback for empty input', () => {
|
|
expect(sanitizeForContentDisposition('')).toBe('download');
|
|
expect(sanitizeForContentDisposition(null)).toBe('download');
|
|
});
|
|
});
|
|
|
|
describe('buildContentDisposition', () => {
|
|
it('emits both filename and filename* parameters', () => {
|
|
const header = buildContentDisposition('DSC_1234.jpg');
|
|
expect(header).toBe('attachment; filename="DSC_1234.jpg"; filename*=UTF-8\'\'DSC_1234.jpg');
|
|
});
|
|
|
|
it('percent-encodes unicode in filename*', () => {
|
|
const header = buildContentDisposition('über.jpg');
|
|
expect(header).toContain('filename="ber.jpg"');
|
|
// %C3%BC is UTF-8 for ü.
|
|
expect(header).toContain("filename*=UTF-8''%C3%BCber.jpg");
|
|
});
|
|
|
|
it('escapes the parens/apostrophe that break RFC 5987 attr-char', () => {
|
|
const header = buildContentDisposition("a'b(c).jpg");
|
|
expect(header).toMatch(/filename\*=UTF-8''/);
|
|
expect(header).not.toMatch(/filename\*=UTF-8''.*\(/);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeForZipEntry', () => {
|
|
it('preserves spaces and parentheses (real camera names use them)', () => {
|
|
expect(sanitizeForZipEntry('IMG 1234 (2).jpg')).toBe('IMG 1234 (2).jpg');
|
|
});
|
|
|
|
it('neutralises path traversal', () => {
|
|
// The exact result keeps leading underscores (so `_DSC1234.NEF`-style
|
|
// Nikon raws survive) — the safety property we care about is that no
|
|
// path separator escapes into the zip entry.
|
|
const out = sanitizeForZipEntry('../../etc/passwd');
|
|
expect(out).not.toMatch(/[\/\\]/);
|
|
expect(out).toContain('etc_passwd');
|
|
expect(sanitizeForZipEntry('a/b\\c.jpg')).toBe('a_b_c.jpg');
|
|
});
|
|
|
|
it('preserves leading underscore for Nikon-style raw names', () => {
|
|
expect(sanitizeForZipEntry('_DSC1234.NEF')).toBe('_DSC1234.NEF');
|
|
});
|
|
|
|
it('strips control characters', () => {
|
|
expect(sanitizeForZipEntry('a |