Переглянути джерело

feat: 添加文件下载功能及配置文件支持

- 新增`config.toml`配置文件,支持存储后端和下载规则配置
- 添加`config`模块,用于加载和解析配置文件
- 在`main.rs`中初始化配置并支持文件下载功能
- 扩展`StorageProvider` trait,支持文件流式下载和下载URL获取
- 在`local.rs`中实现本地文件流式下载功能
- 添加`actix-files`、`serde`、`toml`等依赖

Signed-off-by: longjin <longjin@DragonOS.org>
longjin 2 тижнів тому
батько
коміт
0089b2af6d
8 змінених файлів з 370 додано та 17 видалено
  1. 143 3
      Cargo.lock
  2. 4 0
      Cargo.toml
  3. 21 0
      config.toml
  4. 50 0
      src/config.rs
  5. 0 1
      src/error.rs
  6. 89 3
      src/main.rs
  7. 40 4
      src/storage/local.rs
  8. 23 6
      src/storage/mod.rs

+ 143 - 3
Cargo.lock

@@ -19,6 +19,29 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "actix-files"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be"
+dependencies = [
+ "actix-http",
+ "actix-service",
+ "actix-utils",
+ "actix-web",
+ "bitflags",
+ "bytes",
+ "derive_more 0.99.19",
+ "futures-core",
+ "http-range",
+ "log",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "v_htmlescape",
+]
+
 [[package]]
 name = "actix-http"
 version = "3.10.0"
@@ -35,7 +58,7 @@ dependencies = [
  "brotli",
  "bytes",
  "bytestring",
- "derive_more",
+ "derive_more 2.0.1",
  "encoding_rs",
  "flate2",
  "foldhash",
@@ -170,7 +193,7 @@ dependencies = [
  "bytestring",
  "cfg-if",
  "cookie",
- "derive_more",
+ "derive_more 2.0.1",
  "encoding_rs",
  "foldhash",
  "futures-core",
@@ -358,7 +381,7 @@ dependencies = [
  "memchr",
  "serde",
  "serde_derive",
- "winnow",
+ "winnow 0.7.6",
 ]
 
 [[package]]
@@ -502,6 +525,12 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
 
+[[package]]
+name = "convert_case"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+
 [[package]]
 name = "cookie"
 version = "0.16.2"
@@ -556,6 +585,19 @@ dependencies = [
  "powerfmt",
 ]
 
+[[package]]
+name = "derive_more"
+version = "0.99.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn",
+]
+
 [[package]]
 name = "derive_more"
 version = "2.0.1"
@@ -772,6 +814,12 @@ dependencies = [
  "itoa",
 ]
 
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -1088,6 +1136,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.8"
@@ -1113,6 +1171,7 @@ dependencies = [
 name = "mirror-proxy"
 version = "0.1.0"
 dependencies = [
+ "actix-files",
  "actix-web",
  "anyhow",
  "askama",
@@ -1121,7 +1180,9 @@ dependencies = [
  "env_logger",
  "lazy_static",
  "log",
+ "serde",
  "tokio",
+ "toml",
 ]
 
 [[package]]
@@ -1370,6 +1431,15 @@ version = "2.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
 
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
 [[package]]
 name = "rustls"
 version = "0.20.9"
@@ -1410,6 +1480,12 @@ dependencies = [
  "untrusted 0.9.0",
 ]
 
+[[package]]
+name = "semver"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
+
 [[package]]
 name = "serde"
 version = "1.0.219"
@@ -1442,6 +1518,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "serde_spanned"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "serde_urlencoded"
 version = "0.7.1"
@@ -1633,6 +1718,40 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "toml"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow 0.5.40",
+]
+
 [[package]]
 name = "tracing"
 version = "0.1.41"
@@ -1671,6 +1790,12 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
 
+[[package]]
+name = "unicase"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.18"
@@ -1724,6 +1849,12 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
+[[package]]
+name = "v_htmlescape"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
@@ -1995,6 +2126,15 @@ version = "0.52.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "winnow"
 version = "0.7.6"

+ 4 - 0
Cargo.toml

@@ -7,6 +7,8 @@ authors = ["longjin <longjin@dragonos.org>"]
 [dependencies]
 actix-web = { version = "4.10.2", features = ["rustls"] }
 anyhow = { version = "1.0.98", features = ["backtrace"] }
+serde = { version = "1.0", features = ["derive"] }
+toml = "0.7"
 askama = "0.13.0"
 async-trait = "0.1.88"
 chrono = "0.4.40"
@@ -14,3 +16,5 @@ env_logger = "0.11.8"
 lazy_static = "1.5.0"
 log = "0.4.27"
 tokio = { version = "1.44.2", features = ["full"] }
+actix-files = "0.6.6"
+

+ 21 - 0
config.toml

@@ -0,0 +1,21 @@
+[storage]
+# 存储后端类型,目前支持local
+backend = "local"
+
+[download_rules]
+# 需要特殊处理的文件后缀列表
+extensions = [
+    "deb",
+    "tar",
+    "gz", 
+    "xz",
+    "rpm",
+    "zip",
+    "html",
+    "md"
+]
+
+# 本地存储配置(当backend=local时生效)
+[storage.local]
+# 本地存储根目录
+root_path = "/tmp/test-mirror-proxy"

+ 50 - 0
src/config.rs

@@ -0,0 +1,50 @@
+use serde::Deserialize;
+use std::{collections::HashSet, path::Path};
+use tokio::fs;
+
+#[derive(Debug, Deserialize)]
+pub struct Config {
+    pub storage: StorageConfig,
+    pub download_rules: DownloadRules,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct StorageConfig {
+    pub local: Option<LocalStorageConfig>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct LocalStorageConfig {
+    pub root_path: String,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct DownloadRules {
+    pub extensions: HashSet<String>,
+}
+
+pub async fn load_config(path: &str) -> anyhow::Result<Config> {
+    let config_str = fs::read_to_string(path).await?;
+    let config: Config = toml::from_str(&config_str)?;
+    Ok(config)
+}
+
+pub fn has_matching_extension(path: &str, extensions: &HashSet<String>) -> bool {
+    let path = Path::new(path);
+    if let Some(ext) = path.extension() {
+        let ext_str = ext.to_string_lossy().to_string();
+        log::debug!(
+            "Checking extension for {:?} against {:?}, extension: {:?}",
+            path,
+            extensions,
+            ext_str
+        );
+        let matches = extensions.contains(&ext_str);
+        if matches {
+            log::debug!("Extension match found for {:?}", path);
+        }
+        matches
+    } else {
+        false
+    }
+}

+ 0 - 1
src/error.rs

@@ -1,5 +1,4 @@
 use actix_web::HttpResponse;
-use anyhow::anyhow;
 use askama::Template;
 
 use crate::render::ErrorTemplate;

+ 89 - 3
src/main.rs

@@ -1,14 +1,22 @@
 use self::error::HttpError;
-use actix_web::{get, web, App, HttpRequest, HttpResponse, HttpServer};
+use crate::config::{has_matching_extension, Config};
+use actix_files::NamedFile;
+use actix_web::{get, http::header, web, App, HttpRequest, HttpResponse, HttpServer};
+use anyhow::Context;
 use storage::select_provider;
 
 use std::path::PathBuf;
+use std::sync::OnceLock;
 
-#[macro_use]
-extern crate anyhow;
 #[macro_use]
 extern crate lazy_static;
 
+#[macro_use]
+extern crate anyhow;
+
+static CONFIG: OnceLock<Config> = OnceLock::new();
+
+mod config;
 mod error;
 mod render;
 mod storage;
@@ -51,6 +59,39 @@ async fn autoindex(req: HttpRequest, path: web::Path<String>) -> HttpResponse {
         }
     };
 
+    // 检查是否匹配下载规则
+    let config = CONFIG.get().expect("Config not initialized");
+    if has_matching_extension(path_str, &config.download_rules.extensions) {
+        match select_provider(path_str) {
+            Some((provider, path_in_provider)) => {
+                if provider.is_local() {
+                    log::debug!("Local storage provider selected, attempting to stream file (path in provider: {:?})", path_in_provider);
+                    if let Some(file) = provider.stream_file(&path_in_provider).await {
+                        return named_file_to_response(
+                            file,
+                            req.headers().get("range").and_then(|h| h.to_str().ok()),
+                            &req,
+                        )
+                        .await
+                        .unwrap_or_else(|_| {
+                            HttpError::internal_error("服务器错误", "文件处理失败")
+                                .to_http_response()
+                        });
+                    }
+                } else if let Some(download_url) = provider.get_download_url(path_str).await {
+                    return HttpResponse::Found()
+                        .append_header((header::LOCATION, download_url))
+                        .finish();
+                }
+                return HttpError::not_found("文件不存在", "请求的下载文件不存在")
+                    .to_http_response();
+            }
+            None => {
+                return HttpError::not_found("路径不存在", "请求的资源不存在").to_http_response()
+            }
+        }
+    }
+
     let entries = match select_provider(path_str) {
         Some((provider, path_in_provider)) => provider.list_directory(&path_in_provider).await,
         None => return HttpError::not_found("路径不存在", "请求的资源不存在").to_http_response(),
@@ -71,8 +112,53 @@ async fn autoindex(req: HttpRequest, path: web::Path<String>) -> HttpResponse {
     }
 }
 
+async fn named_file_to_response(
+    file: NamedFile,
+    range_header: Option<&str>,
+    req: &HttpRequest,
+) -> anyhow::Result<HttpResponse> {
+    let metadata = file.file().metadata().context("无法获取文件元数据")?;
+    let name = file
+        .path()
+        .file_name()
+        .ok_or(anyhow!("文件名无效"))?
+        .to_string_lossy()
+        .into_owned();
+    let mut response = file.into_response(req);
+
+    // 设置强制下载的headers
+    response.headers_mut().insert(
+        header::CONTENT_TYPE,
+        "application/octet-stream".parse().unwrap(),
+    );
+    response.headers_mut().insert(
+        header::CONTENT_DISPOSITION,
+        format!("attachment; filename=\"{}\"", name)
+            .parse()
+            .unwrap(),
+    );
+
+    // 保持原有的内容长度和断点续传支持
+    response.headers_mut().insert(
+        header::CONTENT_LENGTH,
+        metadata.len().to_string().parse().unwrap(),
+    );
+    if let Some(range) = range_header {
+        response
+            .headers_mut()
+            .insert(header::RANGE, range.to_string().parse().unwrap());
+    }
+
+    Ok(response)
+}
+
 #[actix_web::main]
 async fn main() -> std::io::Result<()> {
+    let config = config::load_config("config.toml")
+        .await
+        .expect("Failed to load config.toml");
+    CONFIG.set(config).expect("CONFIG already initialized");
+
     let mut builder = env_logger::Builder::from_default_env();
     if std::env::var_os("RUST_LOG").is_none() {
         builder.filter_level(log::LevelFilter::Info);

+ 40 - 4
src/storage/local.rs

@@ -1,5 +1,7 @@
 use std::path::{Path, PathBuf};
 
+use actix_files::NamedFile;
+use anyhow;
 use async_trait::async_trait;
 use tokio::fs;
 
@@ -32,14 +34,14 @@ impl LocalStorageProvider {
     ) -> anyhow::Result<super::StorageEntry> {
         let file_name = ent.file_name().to_string_lossy().to_string();
         let metadata = ent.metadata().await.map_err(|e| {
-            anyhow!(
+            anyhow::anyhow!(
                 "Failed to read metadata for file {}: {}",
                 self.ent_path_in_provider(path_in_provider, ent),
                 e
             )
         })?;
         let modified = metadata.modified().map_err(|e| {
-            anyhow!(
+            anyhow::anyhow!(
                 "Failed to get modified time for file {}: {}",
                 self.ent_path_in_provider(path_in_provider, ent),
                 e
@@ -63,8 +65,34 @@ impl LocalStorageProvider {
     }
 }
 
+impl LocalStorageProvider {
+    fn abs_path(&self, path_in_provider: &str) -> anyhow::Result<PathBuf> {
+        let full_path = format!("{}/{}", self.root_path, path_in_provider);
+        let full_path = PathBuf::from(&full_path).canonicalize().map_err(|e| {
+            anyhow!(
+                "Failed to canonicalize path {}, err: {}",
+                path_in_provider,
+                e
+            )
+        })?;
+        Ok(full_path)
+    }
+}
+
 #[async_trait]
 impl StorageProvider for LocalStorageProvider {
+    fn is_local(&self) -> bool {
+        true
+    }
+
+    async fn stream_file(&self, path_in_provider: &str) -> Option<NamedFile> {
+        let file_path = self.abs_path(path_in_provider).ok()?;
+        match NamedFile::open_async(file_path).await {
+            Ok(file) => Some(file),
+            Err(_) => None,
+        }
+    }
+
     fn path_in_provider(&self, full_path: &str) -> Option<String> {
         if full_path.starts_with(&self.req_path_prefix) {
             Some(full_path[self.req_path_prefix.len()..].to_string())
@@ -73,14 +101,22 @@ impl StorageProvider for LocalStorageProvider {
         }
     }
 
+    async fn get_download_url(&self, _full_path: &str) -> Option<String> {
+        // should not impl for local storage
+        None
+    }
+
     async fn list_directory(&self, path_in_provider: &str) -> Option<Vec<super::StorageEntry>> {
         let mut entries = Vec::new();
-        let full_path = format!("{}/{}", self.root_path, path_in_provider);
-        let full_path = PathBuf::from(&full_path).canonicalize();
+        let full_path = self.abs_path(path_in_provider);
+
         if full_path.is_err() {
+            let e = full_path.unwrap_err();
+            log::error!("list_directory failed: {:?}", e);
             // 路径解析失败
             return None;
         }
+
         let full_path = full_path.unwrap();
         if !full_path.is_dir() {
             log::debug!("Path {} is not a directory", full_path.display());

+ 23 - 6
src/storage/mod.rs

@@ -1,15 +1,19 @@
-use std::{path::Path, sync::Arc, time::SystemTime};
-
-use async_trait::async_trait;
+use std::{sync::Arc, time::SystemTime};
 
 use crate::BASE_PATH;
+use actix_files::NamedFile;
+use async_trait::async_trait;
 
 pub mod local;
 
 lazy_static! {
-    static ref STORAGE_PROVIDER: Arc<dyn StorageProvider> = Arc::new(
-        local::LocalStorageProvider::new("./".into(), BASE_PATH.to_string())
-    );
+    static ref STORAGE_PROVIDER: Arc<dyn StorageProvider> = {
+        let config = crate::CONFIG.get().expect("Config not initialized");
+        Arc::new(local::LocalStorageProvider::new(
+            config.storage.local.as_ref().unwrap().root_path.clone(),
+            BASE_PATH.to_string(),
+        ))
+    };
 }
 
 #[async_trait]
@@ -17,6 +21,19 @@ pub trait StorageProvider: Sync + Send {
     async fn list_directory(&self, path_in_provider: &str) -> Option<Vec<StorageEntry>>;
     /// 根据完整的请求路径,返回在存储提供者中的路径
     fn path_in_provider(&self, full_path: &str) -> Option<String>;
+    /// 获取文件的下载URL(适用于特定后缀的文件)
+    async fn get_download_url(&self, full_path: &str) -> Option<String>;
+
+    /// 是否是本地存储
+    fn is_local(&self) -> bool {
+        false
+    }
+
+    /// 流式返回文件内容
+    #[allow(unused)]
+    async fn stream_file(&self, path_in_provider: &str) -> Option<NamedFile> {
+        None
+    }
 }
 
 #[derive(Debug, Clone)]