Files
picpeak/frontend/src/utils/contentDisposition.ts
T
Paul Nothaft 38343e62de 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.
2026-05-17 00:25:12 +02:00

46 lines
1.7 KiB
TypeScript

/**
* 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;
}