feat: support noscript fallback (#602)

pull/603/head
sigoden 2025-08-02 09:50:00 +08:00 committed by GitHub
parent 459a4d4f4a
commit 089d30c5a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 182 additions and 51 deletions

View File

@ -4,6 +4,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<noscript>
<meta http-equiv="refresh" content="0; url=?noscript">
</noscript>
<link rel="icon" type="image/x-icon" href="__ASSETS_PREFIX__favicon.ico">
<link rel="stylesheet" href="__ASSETS_PREFIX__index.css">
</head>

View File

@ -3,6 +3,7 @@ mod auth;
mod http_logger;
mod http_utils;
mod logger;
mod noscript;
mod server;
mod utils;

100
src/noscript.rs Normal file
View File

@ -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<String> {
let mut html = String::new();
let title = format!("Index of {}", escape_str_pcdata(&data.href));
html.push_str("<html>\n");
html.push_str("<head>\n");
html.push_str(&format!("<title>{title}</title>\n"));
html.push_str(
r#"<style>
td {
padding: 0.2rem;
text-align: left;
}
td:nth-child(3) {
text-align: right;
}
</style>
"#,
);
html.push_str("</head>\n");
html.push_str("<body>\n");
html.push_str(&format!("<h1>{title}</h1>\n"));
html.push_str("<table>\n");
html.push_str(" <tbody>\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(" </tbody>\n");
html.push_str("</table>\n");
html.push_str("</body>\n");
Ok(html)
}
fn render_parent() -> String {
let value = "../";
format!("<tr><td><a href=\"{value}\">{value}</a></td><td></td><td></td></tr>")
}
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!("<tr><td><a href=\"{href}{suffix}\">{name}{suffix}</a></td><td>{mtime}</td><td>{size}</td></tr>")
}
fn format_mtime(mtime: u64) -> Option<String> {
let datetime = DateTime::<Utc>::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])
}
}

View File

@ -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<Self>,
req: Request,
is_microsoft_webdav: bool,
) -> Result<Response> {
pub async fn handle(self: Arc<Self>, req: Request) -> Result<Response> {
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<String, String> = form_urlencoded::parse(query.as_bytes())
let mut query_params: HashMap<String, String> = 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<String>,
paths: Vec<PathItem>,
}
#[derive(Debug, Serialize)]
struct EditData {
href: String,
kind: DataKind,
uri_prefix: String,
allow_upload: bool,
allow_delete: bool,
auth: bool,
user: Option<String>,
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<String>,
pub paths: Vec<PathItem>,
}
#[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<String>,
editable: bool,
}
fn to_timestamp(time: &SystemTime) -> u64 {
time.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()

View File

@ -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#"<td><a href="index.html">index.html</a></td>"#));
Ok(())
}
#[rstest]
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;