Add Content-Disposition header for proxied images#6440
Add Content-Disposition header for proxied images#6440EduardoLZevallos wants to merge 16 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a meaningful download filename for proxied images by deriving a sanitized filename from the original remote image URL and sending it via an inline Content-Disposition header on proxy responses (scoped to /image/proxy only, leaving direct image downloads unchanged as requested in #6354).
Changes:
- Extend the proxy response path to compute a download filename (including adjusted extension when processing params are used) and inject a
Content-Disposition: inlineheader. - Add filename extraction + sanitization helpers to prevent header injection/spoofing characters.
- Add Rust unit tests for filename/disposition behavior and API tests asserting the new header for proxied thumbnails.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
crates/routes/src/images/download.rs |
Computes a safe filename from proxied URL paths and sets Content-Disposition for proxied image responses; adds unit tests. |
api_tests/src/image.spec.ts |
Adds integration assertions that proxied image responses include the expected Content-Disposition header. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn sanitize_download_filename(name: &str) -> Option<String> { | ||
| let mut sanitized = name.to_string(); | ||
| sanitized.retain(|c| !is_unsafe_download_filename_char(c)); | ||
|
|
||
| if sanitized.is_empty() { | ||
| None | ||
| } else { | ||
| Some(sanitized) | ||
| } |
There was a problem hiding this comment.
Whats with the AI review, did you enable this?
There was a problem hiding this comment.
yea. Didn't think it'd just automatically add it to every repo I create a PR. Will turn it off sorry about that. Thanks for feedback will take a look this week.
| | '\u{2060}'..='\u{2064}' | ||
| | '\u{2066}'..='\u{206F}' | ||
| | '\u{FEFF}' | ||
| ) |
There was a problem hiding this comment.
Please add a link with details what these chars are.
|
|
||
| if let Some(addr) = req.head().peer_addr { | ||
| client_req = client_req.header("X-Forwarded-For", addr.to_string()); | ||
| } |
There was a problem hiding this comment.
Nevermind this was actually duplicated, so good to remove it.
| } | ||
|
|
||
| /// Format a `Content-Disposition: inline` header value for the given filename. | ||
| fn inline_content_disposition(name: &str) -> Option<String> { |
There was a problem hiding this comment.
Rename to set_content_disposition, pass in &mut HttpResponse and set the header directly in this method. Then its not necessary to have an if statement in do_get_image().
| } | ||
|
|
||
| /// Extract the last path segment from a URL path string for use as a download filename | ||
| fn filename_from_url(url: &str) -> Option<String> { |
There was a problem hiding this comment.
This method is only called once so move the code inline. Some other methods also look unnecessary.
| if let Some(file_type) = output_file_type { | ||
| Some(filename_with_extension(&filename, file_type)) | ||
| } else { | ||
| Some(filename) |
There was a problem hiding this comment.
This means the file will be saved without extension and users wont be able to open it. If theres no better option at least fallback to .jpg so it can be opend with an image viewer (which will also be able to handle other image types).
There was a problem hiding this comment.
There's also already a file_type function that's handles this, so this entire function isn't necessary.
| fn sanitize_download_filename(name: &str) -> Option<String> { | ||
| let mut sanitized = name.to_string(); | ||
| sanitized.retain(|c| !is_unsafe_download_filename_char(c)); | ||
|
|
||
| if sanitized.is_empty() { | ||
| None | ||
| } else { | ||
| Some(sanitized) | ||
| } |
There was a problem hiding this comment.
Whats with the AI review, did you enable this?
dessalines
left a comment
There was a problem hiding this comment.
Remove all this manual sanitization, and use utf8_percent_encode like the other image downloads are doing.
Also there are a bunch of duped functions that makes it seem like this was AI coded.
| fn filename_with_extension(filename: &str, file_type: PictrsFileType) -> String { | ||
| let stem = match filename.rsplit_once('.') { | ||
| Some((stem, _)) if !stem.is_empty() => stem, | ||
| _ => filename, | ||
| }; | ||
|
|
||
| format!("{stem}.{}", file_type) |
There was a problem hiding this comment.
What is this for? The file_type function already exists.
| if let Some(file_type) = output_file_type { | ||
| Some(filename_with_extension(&filename, file_type)) | ||
| } else { | ||
| Some(filename) |
There was a problem hiding this comment.
There's also already a file_type function that's handles this, so this entire function isn't necessary.
|
bump @EduardoLZevallos |
Apologies. I’m going to pick this back up later in the week / this weekend. |
|
No probs, thx. |
| if (post.thumbnail_url) { | ||
| const proxyResponse = await fetch(post.thumbnail_url); | ||
| const contentDisposition = proxyResponse.headers.get("content-disposition"); | ||
| expect(contentDisposition).not.toBeNull(); |
There was a problem hiding this comment.
This line is redundant as you are checking against a concrete value below. Same in line 244.
…. set_content_disposition only called if a download_filename exist. filename is no longer optional in set_content_disposition
|
CI error from clippy: |
dessalines
left a comment
There was a problem hiding this comment.
Other than the one comment above from nutomic, everything looks good to me.
…ilename-proxied-images # Conflicts: # api_tests/src/image.spec.ts
|
run_federation_tests are failing. |
The 'Images in remote image post are proxied if setting enabled' test was fetching the proxied thumbnail URL immediately after post creation, before pictrs had finished downloading and caching the remote image. This caused a race condition where the Content-Disposition header was sometimes missing, leading to CI failures. Replace the single-shot fetch with a waitUntilSuccess retry loop that polls the proxied URL until the response is OK and the expected Content-Disposition header is present. This matches the existing pattern used elsewhere in the test suite for waiting on backgrounded pictrs operations.
|
Looks like content disposition is not included in the response. Add |
will do |
Fix the download filename for proxied images by setting an
inlineContent-Dispositionheader on proxy responses.This change derives the filename from the proxied image URL, sanitizes it, and uses it in the response header so downloads keep a meaningful name.
This is intentionally scoped to proxied images only, matching the behavior requested in #6354. Direct image downloads are left unchanged.