From 089d30c5a58775dea5710d08c87692df5ccc4661 Mon Sep 17 00:00:00 2001 From: sigoden Date: Sat, 2 Aug 2025 09:50:00 +0800 Subject: [PATCH] feat: support noscript fallback (#602) --- assets/index.html | 3 ++ src/main.rs | 1 + src/noscript.rs | 100 +++++++++++++++++++++++++++++++++++++++ src/server.rs | 116 ++++++++++++++++++++++++++-------------------- tests/http.rs | 13 ++++++ 5 files changed, 182 insertions(+), 51 deletions(-) create mode 100644 src/noscript.rs diff --git a/assets/index.html b/assets/index.html index d814aa0..75bd2a3 100644 --- a/assets/index.html +++ b/assets/index.html @@ -4,6 +4,9 @@ + diff --git a/src/main.rs b/src/main.rs index 6a7d160..f57381f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod auth; mod http_logger; mod http_utils; mod logger; +mod noscript; mod server; mod utils; diff --git a/src/noscript.rs b/src/noscript.rs new file mode 100644 index 0000000..d40a0f7 --- /dev/null +++ b/src/noscript.rs @@ -0,0 +1,100 @@ +use crate::{ + server::{IndexData, PathItem, PathType, MAX_SUBPATHS_COUNT}, + utils::encode_uri, +}; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use xml::escape::escape_str_pcdata; + +pub fn detect_noscript(user_agent: &str) -> bool { + [ + "lynx/", "w3m/", "links ", "elinks/", "curl/", "wget/", "httpie/", "aria2/", + ] + .iter() + .any(|v| user_agent.starts_with(v)) +} + +pub fn generate_noscript_html(data: &IndexData) -> Result { + let mut html = String::new(); + + let title = format!("Index of {}", escape_str_pcdata(&data.href)); + + html.push_str("\n"); + html.push_str("\n"); + html.push_str(&format!("{title}\n")); + html.push_str( + r#" +"#, + ); + html.push_str("\n"); + html.push_str("\n"); + html.push_str(&format!("

{title}

\n")); + html.push_str("\n"); + html.push_str(" \n"); + html.push_str(&format!(" {}\n", render_parent())); + + for path in &data.paths { + html.push_str(&format!(" {}\n", render_path_item(path))); + } + + html.push_str(" \n"); + html.push_str("
\n"); + html.push_str("\n"); + + Ok(html) +} + +fn render_parent() -> String { + let value = "../"; + format!("{value}") +} + +fn render_path_item(path: &PathItem) -> String { + let href = encode_uri(&path.name); + let suffix = if path.path_type.is_dir() { "/" } else { "" }; + let name = escape_str_pcdata(&path.name); + let mtime = format_mtime(path.mtime).unwrap_or_default(); + let size = format_size(path.size, path.path_type); + + format!("{name}{suffix}{mtime}{size}") +} + +fn format_mtime(mtime: u64) -> Option { + let datetime = DateTime::::from_timestamp_millis(mtime as _)?; + Some(datetime.format("%Y-%m-%dT%H:%M:%S.%3fZ").to_string()) +} + +fn format_size(size: u64, path_type: PathType) -> String { + if path_type.is_dir() { + let unit = if size == 1 { "item" } else { "items" }; + let num = match size >= MAX_SUBPATHS_COUNT { + true => format!(">{}", MAX_SUBPATHS_COUNT - 1), + false => size.to_string(), + }; + format!("{num} {unit}") + } else { + if size == 0 { + return "0 B".to_string(); + } + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let i = (size as f64).log2() / 10.0; + let i = i.floor() as usize; + + if i >= UNITS.len() { + // Handle extremely large numbers beyond Terabytes + return format!("{:.2} PB", size as f64 / 1024.0f64.powi(5)); + } + + let size = size as f64 / 1024.0f64.powi(i as i32); + format!("{:.2} {}", size, UNITS[i]) + } +} diff --git a/src/server.rs b/src/server.rs index c7165cd..a100006 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,6 +2,7 @@ use crate::auth::{www_authenticate, AccessPaths, AccessPerm}; use crate::http_utils::{body_full, IncomingStream, LengthLimitedStream}; +use crate::noscript::{detect_noscript, generate_noscript_html}; use crate::utils::{ decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, parse_range, try_get_file_name, @@ -63,7 +64,7 @@ const BUF_SIZE: usize = 65536; const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M const HEALTH_CHECK_PATH: &str = "__dufs__/health"; -const MAX_SUBPATHS_COUNT: u64 = 1000; +pub const MAX_SUBPATHS_COUNT: u64 = 1000; pub struct Server { args: Args, @@ -110,18 +111,12 @@ impl Server { let uri = req.uri().clone(); let assets_prefix = &self.assets_prefix; let enable_cors = self.args.enable_cors; - let is_microsoft_webdav = req - .headers() - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(|v| v.starts_with("Microsoft-WebDAV-MiniRedir/")) - .unwrap_or_default(); let mut http_log_data = self.args.http_logger.data(&req); if let Some(addr) = addr { http_log_data.insert("remote_addr".to_string(), addr.ip().to_string()); } - let mut res = match self.clone().handle(req, is_microsoft_webdav).await { + let mut res = match self.clone().handle(req).await { Ok(res) => { http_log_data.insert("status".to_string(), res.status().as_u16().to_string()); if !uri.path().starts_with(assets_prefix) { @@ -141,28 +136,33 @@ impl Server { } }; - if is_microsoft_webdav { - // microsoft webdav requires this. - res.headers_mut() - .insert(CONNECTION, HeaderValue::from_static("close")); - } if enable_cors { add_cors(&mut res); } Ok(res) } - pub async fn handle( - self: Arc, - req: Request, - is_microsoft_webdav: bool, - ) -> Result { + pub async fn handle(self: Arc, req: Request) -> Result { let mut res = Response::default(); let req_path = req.uri().path(); let headers = req.headers(); let method = req.method().clone(); + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_lowercase()) + .unwrap_or_default(); + + let is_microsoft_webdav = user_agent.starts_with("microsoft-webdav-miniredir/"); + + if is_microsoft_webdav { + // microsoft webdav requires this. + res.headers_mut() + .insert(CONNECTION, HeaderValue::from_static("close")); + } + let relative_path = match self.resolve_path(req_path) { Some(v) => v, None => { @@ -198,10 +198,14 @@ impl Server { }; let query = req.uri().query().unwrap_or_default(); - let query_params: HashMap = form_urlencoded::parse(query.as_bytes()) + let mut query_params: HashMap = form_urlencoded::parse(query.as_bytes()) .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); + if detect_noscript(&user_agent) { + query_params.insert("noscript".to_string(), String::new()); + } + if method.as_str() == "CHECKAUTH" { *res.body_mut() = body_full(user.clone().unwrap_or_default()); return Ok(res); @@ -1203,6 +1207,10 @@ impl Server { res.headers_mut() .typed_insert(ContentType::from(mime_guess::mime::APPLICATION_JSON)); serde_json::to_string_pretty(&data)? + } else if has_query_flag(query_params, "noscript") { + res.headers_mut() + .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8)); + generate_noscript_html(&data)? } else { res.headers_mut() .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8)); @@ -1417,45 +1425,33 @@ impl Server { } #[derive(Debug, Serialize, PartialEq)] -enum DataKind { +pub enum DataKind { Index, Edit, View, } #[derive(Debug, Serialize)] -struct IndexData { - href: String, - kind: DataKind, - uri_prefix: String, - allow_upload: bool, - allow_delete: bool, - allow_search: bool, - allow_archive: bool, - dir_exists: bool, - auth: bool, - user: Option, - paths: Vec, -} - -#[derive(Debug, Serialize)] -struct EditData { - href: String, - kind: DataKind, - uri_prefix: String, - allow_upload: bool, - allow_delete: bool, - auth: bool, - user: Option, - editable: bool, +pub struct IndexData { + pub href: String, + pub kind: DataKind, + pub uri_prefix: String, + pub allow_upload: bool, + pub allow_delete: bool, + pub allow_search: bool, + pub allow_archive: bool, + pub dir_exists: bool, + pub auth: bool, + pub user: Option, + pub paths: Vec, } #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)] -struct PathItem { - path_type: PathType, - name: String, - mtime: u64, - size: u64, +pub struct PathItem { + pub path_type: PathType, + pub name: String, + pub mtime: u64, + pub size: u64, } impl PathItem { @@ -1533,14 +1529,20 @@ impl PathItem { } } -#[derive(Debug, Serialize, Eq, PartialEq)] -enum PathType { +#[derive(Debug, Serialize, Clone, Copy, Eq, PartialEq)] +pub enum PathType { Dir, SymlinkDir, File, SymlinkFile, } +impl PathType { + pub fn is_dir(&self) -> bool { + matches!(self, Self::Dir | Self::SymlinkDir) + } +} + impl Ord for PathType { fn cmp(&self, other: &Self) -> Ordering { let to_value = |t: &Self| -> u8 { @@ -1559,6 +1561,18 @@ impl PartialOrd for PathType { } } +#[derive(Debug, Serialize)] +struct EditData { + href: String, + kind: DataKind, + uri_prefix: String, + allow_upload: bool, + allow_delete: bool, + auth: bool, + user: Option, + editable: bool, +} + fn to_timestamp(time: &SystemTime) -> u64 { time.duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() diff --git a/tests/http.rs b/tests/http.rs index 28c9d79..4627acc 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -82,6 +82,19 @@ fn get_dir_simple(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { Ok(()) } +#[rstest] +fn get_dir_noscript(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { + let resp = reqwest::blocking::get(format!("{}?noscript", server.url()))?; + assert_eq!(resp.status(), 200); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "text/html; charset=utf-8" + ); + let text = resp.text().unwrap(); + assert!(text.contains(r#"index.html"#)); + Ok(()) +} + #[rstest] fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;