diff --git a/Cargo.lock b/Cargo.lock
index 799350f..3952d43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "alphanumeric-sort"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77e9c9abb82613923ec78d7a461595d52491ba7240f3c64c0bbe0e6d98e0fce0"
+
[[package]]
name = "assert_cmd"
version = "2.0.4"
@@ -341,6 +347,7 @@ checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
name = "dufs"
version = "0.29.0"
dependencies = [
+ "alphanumeric-sort",
"assert_cmd",
"assert_fs",
"async-stream",
@@ -350,10 +357,12 @@ dependencies = [
"clap",
"clap_complete",
"diqwest",
+ "form_urlencoded",
"futures",
"headers",
"hyper",
"if-addrs",
+ "indexmap",
"lazy_static",
"log",
"md5",
diff --git a/Cargo.toml b/Cargo.toml
index a0ab903..2776d6d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,8 @@ log = "0.4"
socket2 = "0.4"
async-stream = "0.3"
walkdir = "2.3"
+form_urlencoded = "1.0"
+alphanumeric-sort = "1.4"
[features]
default = ["tls"]
@@ -53,6 +55,7 @@ regex = "1"
url = "2"
diqwest = { version = "1", features = ["blocking"] }
predicates = "2"
+indexmap = "1.9"
[profile.release]
lto = true
diff --git a/assets/index.css b/assets/index.css
index ff17861..c42ae00 100644
--- a/assets/index.css
+++ b/assets/index.css
@@ -131,7 +131,16 @@ body {
padding-left: 0.6em;
}
-.paths-table tr:hover {
+.paths-table thead a {
+ color: unset;
+ text-decoration: none;
+}
+
+.paths-table thead a > span {
+ padding-left: 2px;
+}
+
+.paths-table tbody tr:hover {
background-color: #fafafa;
}
@@ -232,7 +241,7 @@ body {
color: #3191ff;
}
- .paths-table tr:hover {
+ .paths-table tbody tr:hover {
background-color: #1a1a1a;
}
}
diff --git a/assets/index.html b/assets/index.html
index e5cc799..927d7fc 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -48,12 +48,6 @@
-
- Name |
- Last modified |
- Size |
- Actions |
-
diff --git a/assets/index.js b/assets/index.js
index df1e2b7..d5d5c1e 100644
--- a/assets/index.js
+++ b/assets/index.js
@@ -6,17 +6,41 @@
* @property {number} size
*/
-// https://stackoverflow.com/a/901144/3642588
-const params = new Proxy(new URLSearchParams(window.location.search), {
- get: (searchParams, prop) => searchParams.get(prop),
-});
+/**
+ * @typedef {object} DATA
+ * @property {string} href
+ * @property {string} uri_prefix
+ * @property {PathItem[]} paths
+ * @property {boolean} allow_upload
+ * @property {boolean} allow_delete
+ * @property {boolean} allow_search
+ * @property {boolean} dir_exists
+ */
-const dirEmptyNote = params.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
+/**
+ * @type {DATA} DATA
+ */
+var DATA;
+
+/**
+ * @type {PARAMS}
+ * @typedef {object} PARAMS
+ * @property {string} q
+ * @property {string} sort
+ * @property {string} order
+ */
+const PARAMS = Object.fromEntries(new URLSearchParams(window.location.search).entries());
+
+const dirEmptyNote = PARAMS.q ? 'No results' : DATA.dir_exists ? 'Empty folder' : 'Folder will be created when a file is uploaded';
/**
* @type Element
*/
let $pathsTable;
+/**
+ * @type Element
+ */
+let $pathsTableHead;
/**
* @type Element
*/
@@ -175,6 +199,67 @@ function addBreadcrumb(href, uri_prefix) {
}
}
+/**
+ * Render path table thead
+ */
+function renderPathsTableHead() {
+ const headerItems = [
+ {
+ name: "name",
+ props: `colspan="2"`,
+ text: "Name",
+ },
+ {
+ name: "mtime",
+ props: ``,
+ text: "Last Modified",
+ },
+ {
+ name: "size",
+ props: ``,
+ text: "Size",
+ }
+ ];
+ $pathsTableHead.insertAdjacentHTML("beforeend", `
+
+ ${headerItems.map(item => {
+ let svg = ``;
+ let order = "asc";
+ if (PARAMS.sort === item.name) {
+ if (PARAMS.order === "asc") {
+ order = "desc";
+ svg = ``
+ } else {
+ svg = ``
+ }
+ }
+ const qs = new URLSearchParams({...PARAMS, order, sort: item.name }).toString();
+ const icon = `${svg}`
+ return `${item.text}${icon} | `
+ }).join("\n")}
+ Actions |
+
+ `);
+}
+
+/**
+ * Render path table tbody
+ */
+function renderPathsTableBody() {
+ if (DATA.paths && DATA.paths.length > 0) {
+ const len = DATA.paths.length;
+ if (len > 0) {
+ $pathsTable.classList.remove("hidden");
+ }
+ for (let i = 0; i < len; i++) {
+ addPath(DATA.paths[i], i);
+ }
+ } else {
+ $emptyFolder.textContent = dirEmptyNote;
+ $emptyFolder.classList.remove("hidden");
+ }
+}
+
/**
* Add pathitem
* @param {PathItem} file
@@ -430,6 +515,7 @@ function encodedStr(rawStr) {
function ready() {
document.title = `Index of ${DATA.href} - Dufs`;
$pathsTable = document.querySelector(".paths-table")
+ $pathsTableHead = document.querySelector(".paths-table thead");
$pathsTableBody = document.querySelector(".paths-table tbody");
$uploadersTable = document.querySelector(".uploaders-table");
$emptyFolder = document.querySelector(".empty-folder");
@@ -437,26 +523,15 @@ function ready() {
if (DATA.allow_search) {
document.querySelector(".searchbar").classList.remove("hidden");
- if (params.q) {
- document.getElementById('search').value = params.q;
+ if (PARAMS.q) {
+ document.getElementById('search').value = PARAMS.q;
}
}
-
addBreadcrumb(DATA.href, DATA.uri_prefix);
- if (Array.isArray(DATA.paths)) {
- const len = DATA.paths.length;
- if (len > 0) {
- $pathsTable.classList.remove("hidden");
- }
- for (let i = 0; i < len; i++) {
- addPath(DATA.paths[i], i);
- }
- if (len == 0) {
- $emptyFolder.textContent = dirEmptyNote;
- $emptyFolder.classList.remove("hidden");
- }
- }
+ renderPathsTableHead();
+ renderPathsTableBody();
+
if (DATA.allow_upload) {
dropzone();
if (DATA.allow_delete) {
diff --git a/src/server.rs b/src/server.rs
index cac6620..47afc58 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -19,6 +19,7 @@ use hyper::header::{
};
use hyper::{Body, Method, StatusCode, Uri};
use serde::Serialize;
+use std::collections::HashMap;
use std::fs::Metadata;
use std::io::SeekFrom;
use std::net::SocketAddr;
@@ -160,6 +161,9 @@ impl Server {
let path = path.as_path();
let query = req.uri().query().unwrap_or_default();
+ let query_params: HashMap = form_urlencoded::parse(query.as_bytes())
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect();
let (is_miss, is_dir, is_file, size) = match fs::metadata(path).await.ok() {
Some(meta) => (false, meta.is_dir(), meta.is_file(), meta.len()),
@@ -182,27 +186,32 @@ impl Server {
Method::GET | Method::HEAD => {
if is_dir {
if render_try_index {
- if query == "zip" {
+ if query_params.contains_key("zip") {
self.handle_zip_dir(path, head_only, &mut res).await?;
- } else if allow_search && query.starts_with("q=") {
- let q = decode_uri(&query[2..]).unwrap_or_default();
- self.handle_search_dir(path, &q, head_only, &mut res)
+ } else if allow_search && query_params.contains_key("q") {
+ self.handle_search_dir(path, &query_params, head_only, &mut res)
.await?;
} else {
- self.handle_render_index(path, headers, head_only, &mut res)
- .await?;
+ self.handle_render_index(
+ path,
+ &query_params,
+ headers,
+ head_only,
+ &mut res,
+ )
+ .await?;
}
} else if render_index || render_spa {
- self.handle_render_index(path, headers, head_only, &mut res)
+ self.handle_render_index(path, &query_params, headers, head_only, &mut res)
.await?;
- } else if query == "zip" {
+ } else if query_params.contains_key("zip") {
self.handle_zip_dir(path, head_only, &mut res).await?;
- } else if allow_search && query.starts_with("q=") {
- let q = decode_uri(&query[2..]).unwrap_or_default();
- self.handle_search_dir(path, &q, head_only, &mut res)
+ } else if allow_search && query_params.contains_key("q") {
+ self.handle_search_dir(path, &query_params, head_only, &mut res)
.await?;
} else {
- self.handle_ls_dir(path, true, head_only, &mut res).await?;
+ self.handle_ls_dir(path, true, &query_params, head_only, &mut res)
+ .await?;
}
} else if is_file {
self.handle_send_file(path, headers, head_only, &mut res)
@@ -211,7 +220,8 @@ impl Server {
self.handle_render_spa(path, headers, head_only, &mut res)
.await?;
} else if allow_upload && req_path.ends_with('/') {
- self.handle_ls_dir(path, false, head_only, &mut res).await?;
+ self.handle_ls_dir(path, false, &query_params, head_only, &mut res)
+ .await?;
} else {
status_not_found(&mut res);
}
@@ -344,6 +354,7 @@ impl Server {
&self,
path: &Path,
exist: bool,
+ query_params: &HashMap,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
@@ -357,13 +368,13 @@ impl Server {
}
}
};
- self.send_index(path, paths, exist, head_only, res)
+ self.send_index(path, paths, exist, query_params, head_only, res)
}
async fn handle_search_dir(
&self,
path: &Path,
- search: &str,
+ query_params: &HashMap,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
@@ -372,7 +383,7 @@ impl Server {
let hidden = Arc::new(self.args.hidden.to_vec());
let hidden = hidden.clone();
let running = self.running.clone();
- let search = search.to_lowercase();
+ let search = query_params.get("q").unwrap().to_lowercase();
let search_paths = tokio::task::spawn_blocking(move || {
let mut it = WalkDir::new(&path_buf).into_iter();
let mut paths: Vec = vec![];
@@ -405,7 +416,7 @@ impl Server {
paths.push(item);
}
}
- self.send_index(path, paths, true, head_only, res)
+ self.send_index(path, paths, true, query_params, head_only, res)
}
async fn handle_zip_dir(
@@ -445,6 +456,7 @@ impl Server {
async fn handle_render_index(
&self,
path: &Path,
+ query_params: &HashMap,
headers: &HeaderMap,
head_only: bool,
res: &mut Response,
@@ -459,7 +471,8 @@ impl Server {
self.handle_send_file(&index_path, headers, head_only, res)
.await?;
} else if self.args.render_try_index {
- self.handle_ls_dir(path, true, head_only, res).await?;
+ self.handle_ls_dir(path, true, query_params, head_only, res)
+ .await?;
} else {
status_not_found(res)
}
@@ -754,10 +767,30 @@ impl Server {
path: &Path,
mut paths: Vec,
exist: bool,
+ query_params: &HashMap,
head_only: bool,
res: &mut Response,
) -> BoxResult<()> {
- paths.sort_unstable();
+ if let Some(sort) = query_params.get("sort") {
+ if sort == "name" {
+ paths.sort_by(|v1, v2| {
+ alphanumeric_sort::compare_str(v1.name.to_lowercase(), v2.name.to_lowercase())
+ })
+ } else if sort == "mtime" {
+ paths.sort_by(|v1, v2| v1.mtime.cmp(&v2.mtime))
+ } else if sort == "size" {
+ paths.sort_by(|v1, v2| v1.size.unwrap_or(0).cmp(&v2.size.unwrap_or(0)))
+ }
+ if query_params
+ .get("order")
+ .map(|v| v == "desc")
+ .unwrap_or_default()
+ {
+ paths.reverse()
+ }
+ } else {
+ paths.sort_unstable();
+ }
let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?));
let data = IndexData {
href,
diff --git a/tests/sort.rs b/tests/sort.rs
new file mode 100644
index 0000000..4563a51
--- /dev/null
+++ b/tests/sort.rs
@@ -0,0 +1,29 @@
+mod fixtures;
+mod utils;
+
+use fixtures::{server, Error, TestServer};
+use rstest::rstest;
+
+#[rstest]
+fn ls_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
+ let url = server.url();
+ let resp = reqwest::blocking::get(format!("{}?sort=name&order=asc", url))?;
+ let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
+ let resp = reqwest::blocking::get(format!("{}?sort=name&order=desc", url))?;
+ let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
+ paths2.reverse();
+ assert_eq!(paths1, paths2);
+ Ok(())
+}
+
+#[rstest]
+fn search_dir_sort_by_name(server: TestServer) -> Result<(), Error> {
+ let url = server.url();
+ let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=asc", url, "test.html"))?;
+ let paths1 = self::utils::retrieve_index_paths(&resp.text()?);
+ let resp = reqwest::blocking::get(format!("{}?q={}&sort=name&order=desc", url, "test.html"))?;
+ let mut paths2 = self::utils::retrieve_index_paths(&resp.text()?);
+ paths2.reverse();
+ assert_eq!(paths1, paths2);
+ Ok(())
+}
diff --git a/tests/utils.rs b/tests/utils.rs
index 5b01cbe..449fbbf 100644
--- a/tests/utils.rs
+++ b/tests/utils.rs
@@ -1,5 +1,5 @@
+use indexmap::IndexSet;
use serde_json::Value;
-use std::collections::HashSet;
#[macro_export]
macro_rules! assert_resp_paths {
@@ -25,7 +25,7 @@ macro_rules! fetch {
}
#[allow(dead_code)]
-pub fn retrieve_index_paths(index: &str) -> HashSet {
+pub fn retrieve_index_paths(index: &str) -> IndexSet {
retrieve_index_paths_impl(index).unwrap_or_default()
}
@@ -35,7 +35,7 @@ pub fn encode_uri(v: &str) -> String {
parts.join("/")
}
-fn retrieve_index_paths_impl(index: &str) -> Option> {
+fn retrieve_index_paths_impl(index: &str) -> Option> {
let lines: Vec<&str> = index.lines().collect();
let line = lines.iter().find(|v| v.contains("DATA ="))?;
let value: Value = line[7..].parse().ok()?;