-
-
Notifications
You must be signed in to change notification settings - Fork 953
Add Content-Disposition header for proxied images #6440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 11 commits
c491b6e
e190303
6b7ef23
a88c6bf
efcae76
b7138fd
dbce4cb
3b6597d
5f46c32
a185fe3
bfa3d12
1c7ab81
c44a072
5580e7e
c7c4083
6f25c62
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,10 @@ afterAll(async () => { | |
| await Promise.allSettled([unfollows(), deleteAllMedia(alpha)]); | ||
| }); | ||
|
|
||
| function inlineContentDisposition(filename: string): string { | ||
| return `inline; filename="${encodeURIComponent(filename)}"`; | ||
| } | ||
|
|
||
| test("Upload image and delete it", async () => { | ||
| const health = await alpha.imageHealth(); | ||
| expect(health.success).toBeTruthy(); | ||
|
|
@@ -168,6 +172,10 @@ test("Purge post, linked image removed", async () => { | |
| }); | ||
|
|
||
| test("Images in remote image post are proxied if setting enabled", async () => { | ||
| const expectedFilename = decodeURIComponent( | ||
| new URL(sampleImage).pathname.split("/").pop() ?? "", | ||
| ); | ||
|
|
||
| let community = await createCommunity(gamma); | ||
| let postRes = await createPost( | ||
| gamma, | ||
|
|
@@ -194,6 +202,14 @@ test("Images in remote image post are proxied if setting enabled", async () => { | |
| // Make sure that it contains `jpg`, to be sure its an image | ||
| expect(post.thumbnail_url?.includes(".jpg")).toBeTruthy(); | ||
|
|
||
| // Proxied image should include a Content-Disposition: inline header | ||
| if (post.thumbnail_url) { | ||
| const proxyResponse = await fetch(post.thumbnail_url); | ||
| const contentDisposition = proxyResponse.headers.get("content-disposition"); | ||
| expect(contentDisposition).not.toBeNull(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @EduardoLZevallos this one too. |
||
| expect(contentDisposition).toBe(inlineContentDisposition(expectedFilename)); | ||
| } | ||
|
|
||
| let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post); | ||
| expect(epsilonPostRes?.post).toBeDefined(); | ||
|
|
||
|
|
@@ -218,6 +234,13 @@ test("Images in remote image post are proxied if setting enabled", async () => { | |
|
|
||
| // Make sure that it contains `jpg`, to be sure its an image | ||
| expect(epsilonPost.thumbnail_url?.includes(".jpg")).toBeTruthy(); | ||
|
|
||
| if (epsilonPost.thumbnail_url) { | ||
| const proxyResponse = await fetch(epsilonPost.thumbnail_url); | ||
| const contentDisposition = proxyResponse.headers.get("content-disposition"); | ||
| expect(contentDisposition).not.toBeNull(); | ||
| expect(contentDisposition).toBe(inlineContentDisposition(expectedFilename)); | ||
| } | ||
| }); | ||
|
|
||
| test("Thumbnail of remote image link is proxied if setting enabled", async () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,10 @@ use super::utils::{adapt_request, convert_header}; | |
| use actix_web::{ | ||
| HttpRequest, | ||
| HttpResponse, | ||
| HttpResponseBuilder, | ||
| Responder, | ||
| body::{BodyStream, BoxBody}, | ||
| http::StatusCode, | ||
| http::{StatusCode, header::CONTENT_DISPOSITION}, | ||
| web::{Data, *}, | ||
| }; | ||
| use lemmy_api_utils::context::LemmyContext; | ||
|
|
@@ -30,14 +31,14 @@ pub async fn get_image( | |
| return Ok(HttpResponse::Unauthorized().finish()); | ||
| } | ||
|
|
||
| let name = &filename.into_inner(); | ||
| let name = filename.into_inner(); | ||
|
|
||
| // If there are no query params, the URL is original | ||
| let pictrs_url = context.settings().pictrs()?.url; | ||
| let processed_url = if params.file_type.is_none() && params.max_size.is_none() { | ||
| format!("{}image/original/{}", pictrs_url, name) | ||
| } else { | ||
| let file_type = file_type(params.file_type, name).unwrap_or_default(); | ||
| let file_type = file_type(params.file_type, &name).unwrap_or_default(); | ||
|
|
||
| let mut url = format!("{}image/process.{}?src={}", pictrs_url, file_type, name); | ||
|
|
||
|
|
@@ -47,7 +48,7 @@ pub async fn get_image( | |
| url | ||
| }; | ||
|
|
||
| do_get_image(processed_url, req, &context).await | ||
| do_get_image(processed_url, req, &context, Some(name)).await | ||
| } | ||
|
|
||
| pub async fn image_proxy( | ||
|
|
@@ -69,11 +70,10 @@ pub async fn image_proxy( | |
| RemoteImage::validate(&mut context.pool(), url.clone().into()).await?; | ||
|
|
||
| let pictrs_config = context.settings().pictrs()?; | ||
| let processed_url = if params.file_type.is_none() && params.max_size.is_none() { | ||
| format!("{}image/original?proxy={}", pictrs_config.url, encoded_url) | ||
| } else { | ||
| let file_type = file_type(params.file_type, url.path()).unwrap_or_default(); | ||
| let output_file_type = (params.file_type.is_some() || params.max_size.is_some()) | ||
| .then(|| file_type(params.file_type.clone(), url.path()).unwrap_or_default()); | ||
|
|
||
| let processed_url = if let Some(file_type) = &output_file_type { | ||
| let mut url = format!( | ||
| "{}image/process.{}?proxy={}", | ||
| pictrs_config.url, file_type, encoded_url | ||
|
|
@@ -83,6 +83,8 @@ pub async fn image_proxy( | |
| url = format!("{url}&thumbnail={size}",); | ||
| } | ||
| url | ||
| } else { | ||
| format!("{}image/original?proxy={}", pictrs_config.url, encoded_url) | ||
| }; | ||
|
|
||
| let proxy_bypass_domains = SiteView::read_local(&mut context.pool()) | ||
|
|
@@ -95,13 +97,15 @@ pub async fn image_proxy( | |
| let bypass_proxy = proxy_bypass_domains | ||
| .iter() | ||
| .any(|s| url.domain().is_some_and(|d| d == s)); | ||
|
|
||
| if bypass_proxy { | ||
| // Bypass proxy and redirect user to original image | ||
| Ok(Either::Left(Redirect::to(url.to_string()).respond_to(&req))) | ||
| } else { | ||
| // Proxy the image data through Lemmy | ||
| let download_filename = download_filename_from_url(url.path(), output_file_type); | ||
| Ok(Either::Right( | ||
| do_get_image(processed_url, req, &context).await?, | ||
| do_get_image(processed_url, req, &context, download_filename).await?, | ||
| )) | ||
| } | ||
| } | ||
|
|
@@ -110,17 +114,14 @@ pub(super) async fn do_get_image( | |
| url: String, | ||
| req: HttpRequest, | ||
| context: &LemmyContext, | ||
| download_filename: Option<String>, | ||
| ) -> LemmyResult<HttpResponse> { | ||
| let mut client_req = adapt_request(&req, url, context); | ||
|
|
||
| if let Some(addr) = req.head().peer_addr { | ||
| client_req = client_req.header("X-Forwarded-For", addr.to_string()); | ||
| } | ||
|
|
||
| if let Some(addr) = req.head().peer_addr { | ||
| client_req = client_req.header("X-Forwarded-For", addr.to_string()); | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dont remove this
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still needs restoring
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nevermind this was actually duplicated, so good to remove it. |
||
|
|
||
| let res = client_req.send().await?; | ||
|
|
||
| if res.status() == http::StatusCode::NOT_FOUND { | ||
|
|
@@ -133,6 +134,10 @@ pub(super) async fn do_get_image( | |
| client_res.insert_header(convert_header(name, value)); | ||
| } | ||
|
|
||
| if let Some(download_filename) = &download_filename { | ||
| set_content_disposition(&mut client_res, download_filename); | ||
| } | ||
|
|
||
| Ok(client_res.body(BodyStream::new(res.bytes_stream()))) | ||
| } | ||
|
|
||
|
|
@@ -154,6 +159,44 @@ enum PictrsFileType { | |
| Webp, | ||
| } | ||
|
|
||
| fn set_content_disposition(client_res: &mut HttpResponseBuilder, filename: &str) { | ||
| let encoded = urlencoding::encode(filename); | ||
| client_res.insert_header(( | ||
| CONTENT_DISPOSITION, | ||
| format!("inline; filename=\"{}\"", encoded), | ||
| )); | ||
| } | ||
|
EduardoLZevallos marked this conversation as resolved.
|
||
|
|
||
| /// Extracts the final path segment from a URL, percent-decodes it, and returns a | ||
| /// download filename. | ||
| /// | ||
| /// If `output_file_type` is set, the extension is replaced with that type. | ||
| /// Otherwise the original extension is preserved, or `.jpg` is added when none exists. | ||
| fn download_filename_from_url( | ||
|
EduardoLZevallos marked this conversation as resolved.
|
||
| path: &str, | ||
| output_file_type: Option<PictrsFileType>, | ||
| ) -> Option<String> { | ||
| let raw = path | ||
| .rsplit('/') | ||
| .next()? | ||
| .split('?') | ||
| .next() | ||
| .filter(|s| !s.is_empty())?; | ||
| let decoded = urlencoding::decode(raw).unwrap_or_else(|_| raw.into()); | ||
| let name = decoded.as_ref(); | ||
|
|
||
| let has_ext = name.rsplit_once('.').is_some_and(|(s, _)| !s.is_empty()); | ||
| let stem = name | ||
| .rsplit_once('.') | ||
| .filter(|(s, _)| !s.is_empty()) | ||
| .map_or(name, |(s, _)| s); | ||
| match output_file_type { | ||
| None if has_ext => Some(name.into()), | ||
| None => Some(format!("{name}.jpg")), | ||
| Some(ft) => Some(format!("{stem}.{ft}")), | ||
| } | ||
| } | ||
|
|
||
| /// Take file type from param, name, or use jpg if nothing is given | ||
| fn file_type(file_type: Option<String>, name: &str) -> LemmyResult<PictrsFileType> { | ||
| let type_str = file_type | ||
|
|
@@ -165,7 +208,12 @@ fn file_type(file_type: Option<String>, name: &str) -> LemmyResult<PictrsFileTyp | |
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use crate::images::download::{PictrsFileType, file_type}; | ||
| use super::{PictrsFileType, download_filename_from_url, set_content_disposition}; | ||
| use crate::images::download::file_type; | ||
| use actix_web::{ | ||
| HttpResponse, | ||
| http::{StatusCode, header::CONTENT_DISPOSITION}, | ||
| }; | ||
| use lemmy_utils::error::LemmyResult; | ||
|
|
||
| #[tokio::test] | ||
|
|
@@ -215,4 +263,64 @@ mod tests { | |
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_download_filename_from_url() { | ||
| assert_eq!( | ||
| download_filename_from_url("/images/photo.png", Some(PictrsFileType::Avif)), | ||
| Some("photo.avif".to_string()) | ||
| ); | ||
|
|
||
| assert_eq!( | ||
| download_filename_from_url("/images/archive", Some(PictrsFileType::Webp)), | ||
| Some("archive.webp".to_string()) | ||
| ); | ||
|
|
||
| assert_eq!( | ||
| download_filename_from_url("/images/photo.tar.gz", Some(PictrsFileType::Jpg)), | ||
| Some("photo.tar.jpg".to_string()) | ||
| ); | ||
|
|
||
| assert_eq!( | ||
| download_filename_from_url("/images/%C3%A9l%C3%A9phant.png", Some(PictrsFileType::Jxl)), | ||
| Some("éléphant.jxl".to_string()) | ||
| ); | ||
|
|
||
| // Without output file type, original extension is preserved | ||
| assert_eq!( | ||
| download_filename_from_url("/images/photo.png", None), | ||
| Some("photo.png".to_string()) | ||
| ); | ||
|
|
||
| // Without output file type and no extension, falls back to .jpg | ||
| assert_eq!( | ||
| download_filename_from_url("/images/noextension", None), | ||
| Some("noextension.jpg".to_string()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_set_content_disposition() { | ||
| let mut builder = HttpResponse::build(StatusCode::OK); | ||
|
|
||
| // ASCII filename: URL-encoded, preserving characters allowed by urlencoding::encode | ||
| set_content_disposition(&mut builder, "photo.jpg"); | ||
| let res = builder.finish(); | ||
| let header = res.headers().get(CONTENT_DISPOSITION).unwrap(); | ||
| assert_eq!(header, "inline; filename=\"photo.jpg\""); | ||
|
|
||
| // Spaces are encoded | ||
| let mut builder2 = HttpResponse::build(StatusCode::OK); | ||
| set_content_disposition(&mut builder2, "my photo.jpg"); | ||
| let res2 = builder2.finish(); | ||
| let header2 = res2.headers().get(CONTENT_DISPOSITION).unwrap(); | ||
| assert_eq!(header2, "inline; filename=\"my%20photo.jpg\""); | ||
|
|
||
| // Non-ASCII characters are UTF-8 percent-encoded | ||
| let mut builder3 = HttpResponse::build(StatusCode::OK); | ||
| set_content_disposition(&mut builder3, "héron.jpg"); | ||
| let res3 = builder3.finish(); | ||
| let header3 = res3.headers().get(CONTENT_DISPOSITION).unwrap(); | ||
| assert_eq!(header3, "inline; filename=\"h%C3%A9ron.jpg\""); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.