Skip to content

Add Content-Disposition header for proxied images#6440

Open
EduardoLZevallos wants to merge 16 commits into
LemmyNet:mainfrom
EduardoLZevallos:fix/6354-download-filename-proxied-images
Open

Add Content-Disposition header for proxied images#6440
EduardoLZevallos wants to merge 16 commits into
LemmyNet:mainfrom
EduardoLZevallos:fix/6354-download-filename-proxied-images

Conversation

@EduardoLZevallos
Copy link
Copy Markdown
Contributor

Fix the download filename for proxied images by setting an inline Content-Disposition header 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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: inline header.
  • 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.

Comment thread crates/routes/src/images/download.rs Outdated
Comment on lines +166 to +174
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)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats with the AI review, did you enable this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/routes/src/images/download.rs Outdated
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
| '\u{FEFF}'
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a link with details what these chars are.

Comment thread crates/routes/src/images/download.rs

if let Some(addr) = req.head().peer_addr {
client_req = client_req.header("X-Forwarded-For", addr.to_string());
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dont remove this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still needs restoring

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind this was actually duplicated, so good to remove it.

Comment thread crates/routes/src/images/download.rs Outdated
}

/// Format a `Content-Disposition: inline` header value for the given filename.
fn inline_content_disposition(name: &str) -> Option<String> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Comment thread crates/routes/src/images/download.rs Outdated
}

/// Extract the last path segment from a URL path string for use as a download filename
fn filename_from_url(url: &str) -> Option<String> {
Copy link
Copy Markdown
Member

@Nutomic Nutomic Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is only called once so move the code inline. Some other methods also look unnecessary.

Comment thread crates/routes/src/images/download.rs Outdated
if let Some(file_type) = output_file_type {
Some(filename_with_extension(&filename, file_type))
} else {
Some(filename)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also already a file_type function that's handles this, so this entire function isn't necessary.

Comment thread crates/routes/src/images/download.rs Outdated
Comment on lines +166 to +174
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)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats with the AI review, did you enable this?

@LemmyNet LemmyNet deleted a comment from Copilot AI Apr 16, 2026
Copy link
Copy Markdown
Member

@dessalines dessalines left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/routes/src/images/download.rs Outdated
Comment on lines +221 to +227
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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this for? The file_type function already exists.

Comment thread crates/routes/src/images/download.rs Outdated
if let Some(file_type) = output_file_type {
Some(filename_with_extension(&filename, file_type))
} else {
Some(filename)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's also already a file_type function that's handles this, so this entire function isn't necessary.

@dessalines
Copy link
Copy Markdown
Member

bump @EduardoLZevallos

@EduardoLZevallos
Copy link
Copy Markdown
Contributor Author

bump @EduardoLZevallos

Apologies. I’m going to pick this back up later in the week / this weekend.

@dessalines
Copy link
Copy Markdown
Member

No probs, thx.

Comment thread api_tests/src/image.spec.ts Outdated
Comment thread api_tests/src/image.spec.ts
Comment thread crates/routes/src/images/download.rs
Comment thread crates/routes/src/images/download.rs Outdated
Comment thread crates/routes/src/images/download.rs Outdated
Comment thread crates/routes/src/images/download.rs Outdated
Comment thread crates/routes/src/images/download.rs Outdated
Comment thread api_tests/src/image.spec.ts Outdated
Comment thread api_tests/src/image.spec.ts Outdated
if (post.thumbnail_url) {
const proxyResponse = await fetch(post.thumbnail_url);
const contentDisposition = proxyResponse.headers.get("content-disposition");
expect(contentDisposition).not.toBeNull();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is redundant as you are checking against a concrete value below. Same in line 244.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EduardoLZevallos this one too.

Comment thread crates/routes/src/images/download.rs Outdated
Comment thread crates/routes/src/images/download.rs Outdated
Comment thread crates/routes/src/images/download.rs Outdated
…. set_content_disposition only called if a download_filename exist. filename is no longer optional in set_content_disposition
@Nutomic
Copy link
Copy Markdown
Member

Nutomic commented May 13, 2026

CI error from clippy:

error: usage of ref pattern
   --> crates/routes/src/images/download.rs:137:15
    |
137 |   if let Some(ref download_filename) = download_filename {
    |               ^^^^^^^^^^^^^^^^^^^^^
    |
    = help: consider using `&` for clarity instead
    = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.95.0/index.html#ref_patterns
    = note: requested on the command line with `-D clippy::ref-patterns`

Copy link
Copy Markdown
Member

@dessalines dessalines left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than the one comment above from nutomic, everything looks good to me.

@EduardoLZevallos
Copy link
Copy Markdown
Contributor Author

run_federation_tests are failing.

FAIL src/image.spec.ts (49.512 s)
  ✓ Upload image and delete it (184 ms)
  ✓ Purge user, uploaded image removed (1585 ms)
  ✓ Purge post, linked image removed (2321 ms)
  ✕ Images in remote image post are proxied if setting enabled (11012 ms)
  ✓ Thumbnail of remote image link is proxied if setting enabled (2223 ms)
  ✓ No image proxying if setting is disabled (5792 ms)
  ✓ Make regular post, and give it a custom thumbnail (663 ms)
  ✓ Create an image post, and make sure a custom thumbnail doesn't overwrite it (628 ms)

  ● Images in remote image post are proxied if setting enabled

    expect(received).toBe(expected) // Object.is equality

    Expected: "inline; filename=\"df5f5b1b174a2b4b6026cc6c8f9395c1.jpg\""
    Received: null

      221 |     const proxyResponse = await fetch(post.thumbnail_url);
      222 |     const contentDisposition = proxyResponse.headers.get("content-disposition");
    > 223 |     expect(contentDisposition).toBe(inlineContentDisposition(expectedFilename));
          |                                ^
      224 |   }
      225 |
      226 |   const epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);

      at Object.<anonymous> (src/image.spec.ts:223:32)

Test Suites: 1 failed, 5 passed, 6 of 10 total
Tests:       1 failed, 2 skipped, 83 passed, 86 total

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.
@Nutomic
Copy link
Copy Markdown
Member

Nutomic commented May 25, 2026

Looks like content disposition is not included in the response. Add dbg!() statements to the Rust code to see where its going wrong.

@EduardoLZevallos
Copy link
Copy Markdown
Contributor Author

Looks like content disposition is not included in the response. Add dbg!() statements to the Rust code to see where its going wrong.

will do

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants