-
-
Notifications
You must be signed in to change notification settings - Fork 952
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 5 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ use super::utils::{adapt_request, convert_header}; | |
| use actix_web::{ | ||
| HttpRequest, | ||
| HttpResponse, | ||
| HttpResponseBuilder, | ||
| Responder, | ||
| body::{BodyStream, BoxBody}, | ||
| http::StatusCode, | ||
|
|
@@ -13,7 +14,7 @@ use lemmy_db_views_local_image::api::{ImageGetParams, ImageProxyParams}; | |
| use lemmy_db_views_local_user::LocalUserView; | ||
| use lemmy_db_views_site::SiteView; | ||
| use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; | ||
| use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; | ||
| use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; | ||
| use std::str::FromStr; | ||
| use strum::{Display, EnumString}; | ||
| use url::Url; | ||
|
|
@@ -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, None).await | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| 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(ref 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,8 @@ pub(super) async fn do_get_image( | |
| client_res.insert_header(convert_header(name, value)); | ||
| } | ||
|
|
||
| set_content_disposition(&mut client_res, download_filename.as_deref()); | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
|
|
||
| Ok(client_res.body(BodyStream::new(res.bytes_stream()))) | ||
| } | ||
|
|
||
|
|
@@ -154,6 +157,52 @@ enum PictrsFileType { | |
| Webp, | ||
| } | ||
|
|
||
| fn set_content_disposition(client_res: &mut HttpResponseBuilder, filename: Option<&str>) { | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
| if let Some(name) = filename { | ||
| let encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
| client_res.insert_header(( | ||
| actix_web::http::header::CONTENT_DISPOSITION, | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
| format!("inline; filename=\"{}\"", encoded), | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| 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 = percent_decode_str(raw).decode_utf8_lossy(); | ||
|
EduardoLZevallos marked this conversation as resolved.
Outdated
|
||
| let name = decoded.as_ref(); | ||
|
|
||
| output_file_type.map_or_else( | ||
| || { | ||
| let has_extension = name | ||
| .rsplit_once('.') | ||
| .map(|(stem, _)| !stem.is_empty()) | ||
| .unwrap_or(false); | ||
| let filename = if has_extension { | ||
| name.to_string() | ||
| } else { | ||
| format!("{name}.jpg") | ||
| }; | ||
| Some(filename) | ||
| }, | ||
| |ft| { | ||
| let stem = match name.rsplit_once('.') { | ||
| Some((stem, _)) if !stem.is_empty() => stem, | ||
| _ => name, | ||
| }; | ||
| 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 +214,9 @@ 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}; | ||
| use lemmy_utils::error::LemmyResult; | ||
|
|
||
| #[tokio::test] | ||
|
|
@@ -215,4 +266,84 @@ 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: all non-alphanumeric characters are percent-encoded | ||
| set_content_disposition(&mut builder, Some("photo.jpg")); | ||
| let res = builder.finish(); | ||
| let header = res | ||
| .headers() | ||
| .get(actix_web::http::header::CONTENT_DISPOSITION) | ||
| .unwrap(); | ||
| assert_eq!(header, "inline; filename=\"photo%2Ejpg\""); | ||
|
|
||
| // Spaces are encoded | ||
| let mut builder2 = HttpResponse::build(StatusCode::OK); | ||
| set_content_disposition(&mut builder2, Some("my photo.jpg")); | ||
| let res2 = builder2.finish(); | ||
| let header2 = res2 | ||
| .headers() | ||
| .get(actix_web::http::header::CONTENT_DISPOSITION) | ||
| .unwrap(); | ||
| assert_eq!(header2, "inline; filename=\"my%20photo%2Ejpg\""); | ||
|
|
||
| // Non-ASCII characters are UTF-8 percent-encoded | ||
| let mut builder3 = HttpResponse::build(StatusCode::OK); | ||
| set_content_disposition(&mut builder3, Some("héron.jpg")); | ||
| let res3 = builder3.finish(); | ||
| let header3 = res3 | ||
| .headers() | ||
| .get(actix_web::http::header::CONTENT_DISPOSITION) | ||
| .unwrap(); | ||
| assert_eq!(header3, "inline; filename=\"h%C3%A9ron%2Ejpg\""); | ||
|
|
||
| // None sets no header | ||
| let mut builder4 = HttpResponse::build(StatusCode::OK); | ||
| set_content_disposition(&mut builder4, None); | ||
| let res4 = builder4.finish(); | ||
| assert!( | ||
| res4 | ||
| .headers() | ||
| .get(actix_web::http::header::CONTENT_DISPOSITION) | ||
| .is_none() | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.