Files
picpeak/backend/__tests__/utils/filenameSanitizer.test.js
T
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
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).
2026-05-14 23:11:00 +02:00

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('abc.jpg')).toBe('abc.jpg');
});
});
describe('uniquifyZipNames', () => {
it('returns unchanged names when there are no collisions', () => {
expect(uniquifyZipNames(['a.jpg', 'b.jpg', 'c.jpg'])).toEqual([
'a.jpg',
'b.jpg',
'c.jpg',
]);
});
it('appends _N before the extension on collision', () => {
expect(uniquifyZipNames(['DSC_1.jpg', 'DSC_1.jpg', 'DSC_1.jpg'])).toEqual([
'DSC_1.jpg',
'DSC_1_1.jpg',
'DSC_1_2.jpg',
]);
});
it('handles dotless names by appending at the end', () => {
expect(uniquifyZipNames(['raw', 'raw'])).toEqual(['raw', 'raw_1']);
});
it('is deterministic for the same input order', () => {
const input = ['A.jpg', 'A.jpg', 'B.jpg', 'A.jpg', 'B.jpg'];
expect(uniquifyZipNames(input)).toEqual([
'A.jpg',
'A_1.jpg',
'B.jpg',
'A_2.jpg',
'B_1.jpg',
]);
});
it('avoids reusing a synthesized name that already exists explicitly', () => {
// The natural `_1` is taken — the next collision must skip to `_2`.
expect(uniquifyZipNames(['DSC.jpg', 'DSC_1.jpg', 'DSC.jpg'])).toEqual([
'DSC.jpg',
'DSC_1.jpg',
'DSC_2.jpg',
]);
});
});