feat: support noscript fallback (#602)
parent
459a4d4f4a
commit
089d30c5a5
|
@ -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>
|
||||
|
|
|
@ -3,6 +3,7 @@ mod auth;
|
|||
mod http_logger;
|
||||
mod http_utils;
|
||||
mod logger;
|
||||
mod noscript;
|
||||
mod server;
|
||||
mod utils;
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
116
src/server.rs
116
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<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()
|
||||
|
|
|
@ -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()?;
|
||||
|
|
Loading…
Reference in New Issue