feat: support noscript fallback (#602)
parent
459a4d4f4a
commit
089d30c5a5
|
@ -4,6 +4,9 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<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="icon" type="image/x-icon" href="__ASSETS_PREFIX__favicon.ico">
|
||||||
<link rel="stylesheet" href="__ASSETS_PREFIX__index.css">
|
<link rel="stylesheet" href="__ASSETS_PREFIX__index.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -3,6 +3,7 @@ mod auth;
|
||||||
mod http_logger;
|
mod http_logger;
|
||||||
mod http_utils;
|
mod http_utils;
|
||||||
mod logger;
|
mod logger;
|
||||||
|
mod noscript;
|
||||||
mod server;
|
mod server;
|
||||||
mod utils;
|
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::auth::{www_authenticate, AccessPaths, AccessPerm};
|
||||||
use crate::http_utils::{body_full, IncomingStream, LengthLimitedStream};
|
use crate::http_utils::{body_full, IncomingStream, LengthLimitedStream};
|
||||||
|
use crate::noscript::{detect_noscript, generate_noscript_html};
|
||||||
use crate::utils::{
|
use crate::utils::{
|
||||||
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, parse_range,
|
decode_uri, encode_uri, get_file_mtime_and_mode, get_file_name, glob, parse_range,
|
||||||
try_get_file_name,
|
try_get_file_name,
|
||||||
|
@ -63,7 +64,7 @@ const BUF_SIZE: usize = 65536;
|
||||||
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
|
const EDITABLE_TEXT_MAX_SIZE: u64 = 4194304; // 4M
|
||||||
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
|
const RESUMABLE_UPLOAD_MIN_SIZE: u64 = 20971520; // 20M
|
||||||
const HEALTH_CHECK_PATH: &str = "__dufs__/health";
|
const HEALTH_CHECK_PATH: &str = "__dufs__/health";
|
||||||
const MAX_SUBPATHS_COUNT: u64 = 1000;
|
pub const MAX_SUBPATHS_COUNT: u64 = 1000;
|
||||||
|
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
args: Args,
|
args: Args,
|
||||||
|
@ -110,18 +111,12 @@ impl Server {
|
||||||
let uri = req.uri().clone();
|
let uri = req.uri().clone();
|
||||||
let assets_prefix = &self.assets_prefix;
|
let assets_prefix = &self.assets_prefix;
|
||||||
let enable_cors = self.args.enable_cors;
|
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);
|
let mut http_log_data = self.args.http_logger.data(&req);
|
||||||
if let Some(addr) = addr {
|
if let Some(addr) = addr {
|
||||||
http_log_data.insert("remote_addr".to_string(), addr.ip().to_string());
|
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) => {
|
Ok(res) => {
|
||||||
http_log_data.insert("status".to_string(), res.status().as_u16().to_string());
|
http_log_data.insert("status".to_string(), res.status().as_u16().to_string());
|
||||||
if !uri.path().starts_with(assets_prefix) {
|
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 {
|
if enable_cors {
|
||||||
add_cors(&mut res);
|
add_cors(&mut res);
|
||||||
}
|
}
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle(
|
pub async fn handle(self: Arc<Self>, req: Request) -> Result<Response> {
|
||||||
self: Arc<Self>,
|
|
||||||
req: Request,
|
|
||||||
is_microsoft_webdav: bool,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let mut res = Response::default();
|
let mut res = Response::default();
|
||||||
|
|
||||||
let req_path = req.uri().path();
|
let req_path = req.uri().path();
|
||||||
let headers = req.headers();
|
let headers = req.headers();
|
||||||
let method = req.method().clone();
|
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) {
|
let relative_path = match self.resolve_path(req_path) {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
|
@ -198,10 +198,14 @@ impl Server {
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = req.uri().query().unwrap_or_default();
|
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()))
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
if detect_noscript(&user_agent) {
|
||||||
|
query_params.insert("noscript".to_string(), String::new());
|
||||||
|
}
|
||||||
|
|
||||||
if method.as_str() == "CHECKAUTH" {
|
if method.as_str() == "CHECKAUTH" {
|
||||||
*res.body_mut() = body_full(user.clone().unwrap_or_default());
|
*res.body_mut() = body_full(user.clone().unwrap_or_default());
|
||||||
return Ok(res);
|
return Ok(res);
|
||||||
|
@ -1203,6 +1207,10 @@ impl Server {
|
||||||
res.headers_mut()
|
res.headers_mut()
|
||||||
.typed_insert(ContentType::from(mime_guess::mime::APPLICATION_JSON));
|
.typed_insert(ContentType::from(mime_guess::mime::APPLICATION_JSON));
|
||||||
serde_json::to_string_pretty(&data)?
|
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 {
|
} else {
|
||||||
res.headers_mut()
|
res.headers_mut()
|
||||||
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
|
.typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8));
|
||||||
|
@ -1417,45 +1425,33 @@ impl Server {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq)]
|
#[derive(Debug, Serialize, PartialEq)]
|
||||||
enum DataKind {
|
pub enum DataKind {
|
||||||
Index,
|
Index,
|
||||||
Edit,
|
Edit,
|
||||||
View,
|
View,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct IndexData {
|
pub struct IndexData {
|
||||||
href: String,
|
pub href: String,
|
||||||
kind: DataKind,
|
pub kind: DataKind,
|
||||||
uri_prefix: String,
|
pub uri_prefix: String,
|
||||||
allow_upload: bool,
|
pub allow_upload: bool,
|
||||||
allow_delete: bool,
|
pub allow_delete: bool,
|
||||||
allow_search: bool,
|
pub allow_search: bool,
|
||||||
allow_archive: bool,
|
pub allow_archive: bool,
|
||||||
dir_exists: bool,
|
pub dir_exists: bool,
|
||||||
auth: bool,
|
pub auth: bool,
|
||||||
user: Option<String>,
|
pub user: Option<String>,
|
||||||
paths: Vec<PathItem>,
|
pub 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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
|
#[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)]
|
||||||
struct PathItem {
|
pub struct PathItem {
|
||||||
path_type: PathType,
|
pub path_type: PathType,
|
||||||
name: String,
|
pub name: String,
|
||||||
mtime: u64,
|
pub mtime: u64,
|
||||||
size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PathItem {
|
impl PathItem {
|
||||||
|
@ -1533,14 +1529,20 @@ impl PathItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Eq, PartialEq)]
|
#[derive(Debug, Serialize, Clone, Copy, Eq, PartialEq)]
|
||||||
enum PathType {
|
pub enum PathType {
|
||||||
Dir,
|
Dir,
|
||||||
SymlinkDir,
|
SymlinkDir,
|
||||||
File,
|
File,
|
||||||
SymlinkFile,
|
SymlinkFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PathType {
|
||||||
|
pub fn is_dir(&self) -> bool {
|
||||||
|
matches!(self, Self::Dir | Self::SymlinkDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Ord for PathType {
|
impl Ord for PathType {
|
||||||
fn cmp(&self, other: &Self) -> Ordering {
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
let to_value = |t: &Self| -> u8 {
|
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 {
|
fn to_timestamp(time: &SystemTime) -> u64 {
|
||||||
time.duration_since(SystemTime::UNIX_EPOCH)
|
time.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
|
|
@ -82,6 +82,19 @@ fn get_dir_simple(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
|
||||||
Ok(())
|
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]
|
#[rstest]
|
||||||
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
|
fn head_dir_zip(#[with(&["-A"])] server: TestServer) -> Result<(), Error> {
|
||||||
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
|
let resp = fetch!(b"HEAD", format!("{}?zip", server.url())).send()?;
|
||||||
|
|
Loading…
Reference in New Issue