diff --git a/Dockerfile b/Dockerfile index 5e35243..113c057 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM rust:1.80-slim AS builder +FROM rust:1.90-slim AS builder RUN apt update \ && apt install -y \ - pkg-config \ - libssl-dev \ + pkg-config \ + libssl-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app @@ -13,14 +13,14 @@ RUN cargo install --path . ######################################################################################################################## -FROM debian:12-slim +FROM debian:13-slim LABEL org.opencontainers.image.source=https://github.com/s373r/freshrss-image-cache-service-rs RUN apt update \ && apt install -y \ - ca-certificates \ - libssl3 \ + ca-certificates \ + libssl3 \ && rm -rf /var/lib/apt/lists/* \ && update-ca-certificates diff --git a/src/app_service.rs b/src/app_service.rs index df37919..38a0f07 100644 --- a/src/app_service.rs +++ b/src/app_service.rs @@ -1,11 +1,11 @@ -use std::error::Error; -use std::fmt; use crate::dtos::CachedImage; use anyhow::{bail, Context, Result}; -use sha3::{Digest, Sha3_256}; -use std::path::PathBuf; use axum::http::HeaderValue; use reqwest::Response; +use sha3::{Digest, Sha3_256}; +use std::error::Error; +use std::fmt; +use std::path::PathBuf; use tokio::fs; use tokio::io::AsyncWriteExt; use url::Url; @@ -15,6 +15,7 @@ pub struct CachedImagePaths { data_folder: PathBuf, data_path: PathBuf, mime_type_path: PathBuf, + filename: String, } #[derive(Clone, Debug)] @@ -31,7 +32,6 @@ pub struct AppService { bypass_info: Option, } - #[derive(Debug)] struct CaptchaError { mime_type: String, @@ -45,8 +45,12 @@ impl fmt::Display for CaptchaError { } impl AppService { - pub fn new(access_token: String, images_dir: PathBuf, disable_https_validation: bool, - bypass_info: Option) -> Self { + pub fn new( + access_token: String, + images_dir: PathBuf, + disable_https_validation: bool, + bypass_info: Option, + ) -> Self { Self { access_token, images_dir, @@ -76,6 +80,7 @@ impl AppService { data: image_content, mime_type, extracted_from_cache, + filename: cached_image_paths.filename, }) } @@ -100,11 +105,13 @@ impl AppService { // Use two first letters of the file name for the intermediate directory, to // make sure we don't end up with a huge number of files. let data_folder = self.images_dir.join(&image_content_file_name[0..2]); + let filename = image_content_file_name.clone(); Ok(CachedImagePaths { data_path: data_folder.join(image_content_file_name), mime_type_path: data_folder.join(mime_type_file_name), data_folder, + filename, }) } @@ -156,26 +163,37 @@ impl AppService { Ok(()) } - async fn try_get_image(&self, image_url: &Url, deploy_workarounds: bool) -> Result<(Response, HeaderValue)> { - let mut client_builder = reqwest::Client::builder() - .danger_accept_invalid_certs(self.disable_https_validation); + async fn try_get_image( + &self, + image_url: &Url, + deploy_workarounds: bool, + ) -> Result<(Response, HeaderValue)> { + let mut client_builder = + reqwest::Client::builder().danger_accept_invalid_certs(self.disable_https_validation); if deploy_workarounds { let bypass = self.bypass_info.as_ref().unwrap(); let proxy = reqwest::Proxy::all(bypass.proxy_url.clone()) .expect("Failed to create a proxy") .basic_auth(&bypass.proxy_login.clone(), &bypass.proxy_password.clone()); - client_builder = - client_builder.danger_accept_invalid_certs(true).proxy(proxy); + client_builder = client_builder + .danger_accept_invalid_certs(true) + .proxy(proxy); } - let client = client_builder.build().expect("Failed to build the HTTP client"); + let client = client_builder + .build() + .expect("Failed to build the HTTP client"); let mut headers = reqwest::header::HeaderMap::new(); headers.insert("user-agent", "curl/7.86.0".parse().unwrap()); headers.insert("accept", "*/*".parse().unwrap()); - let image_response = client.get(image_url.clone()).headers(headers).send().await?; + let image_response = client + .get(image_url.clone()) + .headers(headers) + .send() + .await?; let Some(mime_type) = image_response.headers().get("content-type").cloned() else { bail!("Failed to get the content-type header for image: {image_url}"); @@ -184,7 +202,10 @@ impl AppService { // CloudFlare often returns CAPTCHAs for images, so we need to check for that. let mime_str = String::from_utf8_lossy(mime_type.as_bytes()); if !mime_str.starts_with("image/") && !mime_str.starts_with("video/") { - return Err(CaptchaError { mime_type: mime_str.parse()? }.into()); + return Err(CaptchaError { + mime_type: mime_str.parse()?, + } + .into()); } Ok((image_response, mime_type)) } diff --git a/src/dtos.rs b/src/dtos.rs index 553ca00..0538d66 100644 --- a/src/dtos.rs +++ b/src/dtos.rs @@ -8,6 +8,7 @@ pub struct CachedImage { pub data: Vec, pub mime_type: String, pub extracted_from_cache: bool, + pub filename: String, } impl IntoResponse for CachedImage { @@ -36,6 +37,50 @@ impl IntoResponse for CachedImage { HeaderValue::from_str(cache_status_value).unwrap(), ); + let filename_with_ext = format!( + "{}.{}", + self.filename, + mime_to_extension(&self.mime_type).unwrap_or("bin") + ); + + headers.insert( + HeaderName::from_str("Content-Disposition").unwrap(), + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", filename_with_ext)) + .unwrap(), + ); + response } } + +fn mime_to_extension(mime_type: &str) -> Option<&'static str> { + match mime_type { + // Common image formats + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + "image/svg+xml" => Some("svg"), + "image/bmp" => Some("bmp"), + "image/x-icon" => Some("ico"), + "image/vnd.microsoft.icon" => Some("ico"), + "image/tiff" => Some("tiff"), + "image/avif" => Some("avif"), + "image/heic" => Some("heic"), + "image/heif" => Some("heif"), + + // Less common but valid image formats + "image/x-bmp" => Some("bmp"), + "image/x-ms-bmp" => Some("bmp"), + "image/apng" => Some("apng"), + "image/jxl" => Some("jxl"), + + // RAW formats + "image/x-canon-cr2" => Some("cr2"), + "image/x-canon-crw" => Some("crw"), + "image/x-nikon-nef" => Some("nef"), + "image/x-sony-arw" => Some("arw"), + + _ => None, + } +}