diff --git a/Cargo.lock b/Cargo.lock index 4f8ca0d..738d918 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,6 +277,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -442,6 +451,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "content_inspector", "diqwest", "form_urlencoded", "futures", diff --git a/Cargo.toml b/Cargo.toml index cbe2700..3d8b0bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ async-stream = "0.3" walkdir = "2.3" form_urlencoded = "1.0" alphanumeric-sort = "1.4" +content_inspector = "0.2.4" [features] default = ["tls"] diff --git a/assets/index.css b/assets/index.css index a1a4908..3b2174f 100644 --- a/assets/index.css +++ b/assets/index.css @@ -108,11 +108,10 @@ body { } .main { - padding: 3em 1em 0; + padding: 3.3em 1em 0; } .empty-folder { - padding-top: 1rem; font-style: italic; } @@ -202,6 +201,25 @@ body { padding-right: 1em; } +.editor { + width: 100%; + height: calc(100vh - 5rem); + border: 1px solid #ced4da; + outline: none; +} + +.save-btn { + margin-left: auto; + margin-right: 2em; + cursor: pointer; + user-select: none; +} + +.not-editable { + font-style: italic; +} + + @media (min-width: 768px) { .path a { min-width: 400px; diff --git a/assets/index.html b/assets/index.html index a882fde..0f35410 100644 --- a/assets/index.html +++ b/assets/index.html @@ -7,59 +7,97 @@ +
+
+
- - - - - - - - - - - - - - - + +
+ \ No newline at end of file diff --git a/assets/index.js b/assets/index.js index dfaa159..90d01c6 100644 --- a/assets/index.js +++ b/assets/index.js @@ -7,9 +7,14 @@ */ /** - * @typedef {object} DATA + * @typedef {IndexDATA|EditDATA} DATA + */ + +/** + * @typedef {object} IndexDATA * @property {string} href * @property {string} uri_prefix + * @property {"Index"} kind * @property {PathItem[]} paths * @property {boolean} allow_upload * @property {boolean} allow_delete @@ -18,6 +23,14 @@ * @property {boolean} dir_exists */ +/** + * @typedef {object} EditDATA + * @property {string} href + * @property {string} uri_prefix + * @property {"Edit"} kind + * @property {string} editable + */ + /** * @type {DATA} DATA */ @@ -57,11 +70,43 @@ let $emptyFolder; /** * @type Element */ -let $newFolder; -/** - * @type Element - */ -let $searchbar; +let $editor; + +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"); + $editor = document.querySelector(".editor"); + + addBreadcrumb(DATA.href, DATA.uri_prefix); + + if (DATA.kind == "Index") { + + document.querySelector(".index-page").classList.remove("hidden"); + + if (DATA.allow_search) { + setupSearch() + } + + if (DATA.allow_archive) { + document.querySelector(".zip-root").classList.remove("hidden"); + } + + renderPathsTableHead(); + renderPathsTableBody(); + + if (DATA.allow_upload) { + dropzone(); + setupUpload(); + } + } else if (DATA.kind == "Edit") { + setupEditor(); + } +} + class Uploader { /** @@ -83,12 +128,12 @@ class Uploader { upload() { const { idx, name } = this; - const url = getUrl(name); + const url = newUrl(name); const encodedName = encodedStr(name); $uploadersTable.insertAdjacentHTML("beforeend", ` - ${getSvg()} + ${getPathSvg()} ${encodedName} @@ -105,7 +150,7 @@ class Uploader { ajax() { Uploader.runnings += 1; - const url = getUrl(this.name); + const url = newUrl(this.name); this.lastUptime = Date.now(); const ajax = new XMLHttpRequest(); ajax.upload.addEventListener("progress", e => this.progress(e), false); @@ -272,7 +317,7 @@ function renderPathsTableBody() { */ function addPath(file, index) { const encodedName = encodedStr(file.name); - let url = getUrl(file.name) + let url = newUrl(file.name) let actionDelete = ""; let actionDownload = ""; let actionMove = ""; @@ -316,10 +361,10 @@ function addPath(file, index) { $pathsTableBody.insertAdjacentHTML("beforeend", ` - ${getSvg(file.path_type)} + ${getPathSvg(file.path_type)} - ${encodedName} + ${encodedName} ${formatMtime(file.mtime)} ${formatSize(file.size).join(" ")} @@ -339,19 +384,16 @@ async function deletePath(index) { if (!confirm(`Delete \`${file.name}\`?`)) return; try { - const res = await fetch(getUrl(file.name), { + const res = await fetch(newUrl(file.name), { method: "DELETE", }); - if (res.status >= 200 && res.status < 300) { - document.getElementById(`addPath${index}`).remove(); - DATA.paths[index] = null; - if (!DATA.paths.find(v => !!v)) { - $pathsTable.classList.add("hidden"); - $emptyFolder.textContent = dirEmptyNote; - $emptyFolder.classList.remove("hidden"); - } - } else { - throw new Error(await res.text()) + await assertFetch(res); + document.getElementById(`addPath${index}`).remove(); + DATA.paths[index] = null; + if (!DATA.paths.find(v => !!v)) { + $pathsTable.classList.add("hidden"); + $emptyFolder.textContent = dirEmptyNote; + $emptyFolder.classList.remove("hidden"); } } catch (err) { alert(`Cannot delete \`${file.name}\`, ${err.message}`); @@ -368,7 +410,7 @@ async function movePath(index) { const file = DATA.paths[index]; if (!file) return; - const fileUrl = getUrl(file.name); + const fileUrl = newUrl(file.name); const fileUrlObj = new URL(fileUrl) const prefix = DATA.uri_prefix.slice(0, -1); @@ -388,11 +430,8 @@ async function movePath(index) { "Destination": newFileUrl, } }); - if (res.status >= 200 && res.status < 300) { - location.href = newFileUrl.split("/").slice(0, -1).join("/") - } else { - throw new Error(await res.text()) - } + await assertFetch(res); + location.href = newFileUrl.split("/").slice(0, -1).join("/") } catch (err) { alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`); } @@ -426,12 +465,13 @@ function dropzone() { * Setup searchbar */ function setupSearch() { + const $searchbar = document.querySelector(".searchbar"); $searchbar.classList.remove("hidden"); $searchbar.addEventListener("submit", event => { event.preventDefault(); const formData = new FormData($searchbar); const q = formData.get("q"); - let href = getUrl(); + let href = baseUrl(); if (q) { href += "?q=" + q; } @@ -442,10 +482,8 @@ function setupSearch() { } } -/** - * Setup upload - */ function setupUpload() { + const $newFolder = document.querySelector(".new-folder"); $newFolder.classList.remove("hidden"); $newFolder.addEventListener("click", () => { const name = prompt("Enter folder name"); @@ -460,19 +498,61 @@ function setupUpload() { }); } +async function setupEditor() { + document.querySelector(".editor-page").classList.remove("hidden");; + + const $download = document.querySelector(".download") + $download.classList.remove("hidden"); + $download.href = baseUrl() + + if (!DATA.editable) { + const $notEditable = document.querySelector(".not-editable"); + $notEditable.classList.remove("hidden"); + $notEditable.textContent = "File is binary or too large."; + return; + } + + const $saveBtn = document.querySelector(".save-btn"); + $saveBtn.classList.remove("hidden"); + $saveBtn.addEventListener("click", saveChange); + + $editor.classList.remove("hidden"); + try { + const res = await fetch(baseUrl()); + await assertFetch(res); + const text = await res.text(); + $editor.value = text; + } catch (err) { + alert(`Failed get file, ${err.message}`); + } +} + +/** + * Save editor change + */ +async function saveChange() { + try { + await fetch(baseUrl(), { + method: "PUT", + body: $editor.value, + }); + } catch (err) { + alert(`Failed to save file, ${err.message}`); + } +} + /** * Create a folder * @param {string} name */ async function createFolder(name) { - const url = getUrl(name); + const url = newUrl(name); try { const res = await fetch(url, { method: "MKCOL", }); - if (res.status >= 200 && res.status < 300) { - location.href = url; - } + await assertFetch(res); + location.href = url; } catch (err) { alert(`Cannot create folder \`${name}\`, ${err.message}`); } @@ -492,15 +572,18 @@ async function addFileEntries(entries, dirs) { } -function getUrl(name) { - let url = location.href.split('?')[0]; +function newUrl(name) { + let url = baseUrl(); if (!url.endsWith("/")) url += "/"; - if (!name) return url; url += name.split("/").map(encodeURIComponent).join("/"); return url; } -function getSvg(path_type) { +function baseUrl() { + return location.href.split('?')[0]; +} + +function getPathSvg(path_type) { switch (path_type) { case "Dir": return ``; @@ -558,30 +641,8 @@ 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"); - $newFolder = document.querySelector(".new-folder"); - $searchbar = document.querySelector(".searchbar"); - - if (DATA.allow_search) { - setupSearch() - } - - if (DATA.allow_archive) { - document.querySelector(".zip-root").classList.remove("hidden"); - } - - addBreadcrumb(DATA.href, DATA.uri_prefix); - renderPathsTableHead(); - renderPathsTableBody(); - - if (DATA.allow_upload) { - dropzone(); - setupUpload(); +async function assertFetch(res) { + if (!(res.status >= 200 && res.status < 300)) { + throw new Error(await res.text()) } } diff --git a/src/server.rs b/src/server.rs index 07b570f..d5e7b09 100644 --- a/src/server.rs +++ b/src/server.rs @@ -29,7 +29,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::SystemTime; use tokio::fs::File; -use tokio::io::{AsyncSeekExt, AsyncWrite}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWrite}; use tokio::{fs, io}; use tokio_util::io::StreamReader; use uuid::Uuid; @@ -43,6 +43,7 @@ const INDEX_JS: &str = include_str!("../assets/index.js"); const FAVICON_ICO: &[u8] = include_bytes!("../assets/favicon.ico"); const INDEX_NAME: &str = "index.html"; const BUF_SIZE: usize = 65536; +const TEXT_MAX_SIZE: u64 = 4194304; // 4M pub struct Server { args: Arc, @@ -232,8 +233,12 @@ impl Server { .await?; } } else if is_file { - self.handle_send_file(path, headers, head_only, &mut res) - .await?; + if query_params.contains_key("edit") { + self.handle_edit_file(path, head_only, &mut res).await?; + } else { + self.handle_send_file(path, headers, head_only, &mut res) + .await?; + } } else if render_spa { self.handle_render_spa(path, headers, head_only, &mut res) .await?; @@ -673,6 +678,41 @@ impl Server { Ok(()) } + async fn handle_edit_file( + &self, + path: &Path, + head_only: bool, + res: &mut Response, + ) -> BoxResult<()> { + let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),); + let (file, meta) = (file?, meta?); + let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?)); + let mut buffer: Vec = vec![]; + file.take(1024).read_to_end(&mut buffer).await?; + let editable = meta.len() <= TEXT_MAX_SIZE && content_inspector::inspect(&buffer).is_text(); + let data = EditData { + href, + kind: DataKind::Edit, + uri_prefix: self.args.uri_prefix.clone(), + allow_upload: self.args.allow_upload, + allow_delete: self.args.allow_delete, + editable, + }; + res.headers_mut() + .typed_insert(ContentType::from(mime_guess::mime::TEXT_HTML_UTF_8)); + let output = self + .html + .replace("__ASSERTS_PREFIX__", &self.assets_prefix) + .replace("__INDEX_DATA__", &serde_json::to_string(&data).unwrap()); + res.headers_mut() + .typed_insert(ContentLength(output.as_bytes().len() as u64)); + if head_only { + return Ok(()); + } + *res.body_mut() = output.into(); + Ok(()) + } + async fn handle_propfind_dir( &self, path: &Path, @@ -855,6 +895,7 @@ impl Server { } let href = format!("/{}", normalize_path(path.strip_prefix(&self.args.path)?)); let data = IndexData { + kind: DataKind::Index, href, uri_prefix: self.args.uri_prefix.clone(), allow_upload: self.args.allow_upload, @@ -1018,9 +1059,16 @@ impl Server { } } +#[derive(Debug, Serialize)] +enum DataKind { + Index, + Edit, +} + #[derive(Debug, Serialize)] struct IndexData { href: String, + kind: DataKind, uri_prefix: String, allow_upload: bool, allow_delete: bool, @@ -1030,6 +1078,16 @@ struct IndexData { paths: Vec, } +#[derive(Debug, Serialize)] +struct EditData { + href: String, + kind: DataKind, + uri_prefix: String, + allow_upload: bool, + allow_delete: bool, + editable: bool, +} + #[derive(Debug, Serialize, Eq, PartialEq, Ord, PartialOrd)] struct PathItem { path_type: PathType, diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 78c3c96..b785670 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -11,9 +11,12 @@ use std::time::{Duration, Instant}; #[allow(dead_code)] pub type Error = Box; +#[allow(dead_code)] +pub const BIN_FILE: &str = "😀.bin"; + /// File names for testing purpose #[allow(dead_code)] -pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", "😀.bin"]; +pub static FILES: &[&str] = &["test.txt", "test.html", "index.html", BIN_FILE]; /// Directory names for testing directory don't exist #[allow(dead_code)] @@ -42,10 +45,17 @@ pub static DIRECTORIES: &[&str] = &["dir1/", "dir2/", "dir3/", DIR_NO_INDEX, DIR pub fn tmpdir() -> TempDir { let tmpdir = assert_fs::TempDir::new().expect("Couldn't create a temp dir for tests"); for file in FILES { - tmpdir - .child(file) - .write_str(&format!("This is {file}")) - .expect("Couldn't write to file"); + if *file == BIN_FILE { + tmpdir + .child(file) + .write_binary(b"bin\0\0123") + .expect("Couldn't write to file"); + } else { + tmpdir + .child(file) + .write_str(&format!("This is {file}")) + .expect("Couldn't write to file"); + } } for directory in DIRECTORIES { if *directory == DIR_ASSETS { @@ -58,10 +68,17 @@ pub fn tmpdir() -> TempDir { if *directory == DIR_NO_INDEX && *file == "index.html" { continue; } - tmpdir - .child(format!("{directory}{file}")) - .write_str(&format!("This is {directory}{file}")) - .expect("Couldn't write to file"); + if *file == BIN_FILE { + tmpdir + .child(format!("{directory}{file}")) + .write_binary(b"bin\0\0123") + .expect("Couldn't write to file"); + } else { + tmpdir + .child(format!("{directory}{file}")) + .write_str(&format!("This is {directory}{file}")) + .expect("Couldn't write to file"); + } } } } diff --git a/tests/http.rs b/tests/http.rs index a7c2979..ee9ff6b 100644 --- a/tests/http.rs +++ b/tests/http.rs @@ -1,9 +1,10 @@ mod fixtures; mod utils; -use fixtures::{server, Error, TestServer}; +use fixtures::{server, Error, TestServer, BIN_FILE}; use rstest::rstest; use serde_json::Value; +use utils::retrive_edit_file; #[rstest] fn get_dir(server: TestServer) -> Result<(), Error> { @@ -103,12 +104,12 @@ fn get_dir_search(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { #[rstest] fn get_dir_search2(#[with(&["-A"])] server: TestServer) -> Result<(), Error> { - let resp = reqwest::blocking::get(format!("{}?q={}", server.url(), "😀.bin"))?; + let resp = reqwest::blocking::get(format!("{}?q={BIN_FILE}", server.url()))?; assert_eq!(resp.status(), 200); let paths = utils::retrieve_index_paths(&resp.text()?); assert!(!paths.is_empty()); for p in paths { - assert!(p.contains("😀.bin")); + assert!(p.contains(BIN_FILE)); } Ok(()) } @@ -177,6 +178,24 @@ fn get_file_404(server: TestServer) -> Result<(), Error> { Ok(()) } +#[rstest] +fn get_file_edit(server: TestServer) -> Result<(), Error> { + let resp = fetch!(b"GET", format!("{}index.html?edit", server.url())).send()?; + assert_eq!(resp.status(), 200); + let editable = retrive_edit_file(&resp.text().unwrap()).unwrap(); + assert!(editable); + Ok(()) +} + +#[rstest] +fn get_file_edit_bin(server: TestServer) -> Result<(), Error> { + let resp = fetch!(b"GET", format!("{}{BIN_FILE}?edit", server.url())).send()?; + assert_eq!(resp.status(), 200); + let editable = retrive_edit_file(&resp.text().unwrap()).unwrap(); + assert!(!editable); + Ok(()) +} + #[rstest] fn head_file_404(server: TestServer) -> Result<(), Error> { let resp = fetch!(b"HEAD", format!("{}404", server.url())).send()?; diff --git a/tests/render.rs b/tests/render.rs index 3a8e9f2..0621fab 100644 --- a/tests/render.rs +++ b/tests/render.rs @@ -1,7 +1,7 @@ mod fixtures; mod utils; -use fixtures::{server, Error, TestServer, DIR_NO_FOUND, DIR_NO_INDEX, FILES}; +use fixtures::{server, Error, TestServer, BIN_FILE, DIR_NO_FOUND, DIR_NO_INDEX, FILES}; use rstest::rstest; #[rstest] @@ -56,11 +56,10 @@ fn render_try_index3( #[case(server(&["--render-try-index"] as &[&str]), false)] #[case(server(&["--render-try-index", "--allow-search"] as &[&str]), true)] fn render_try_index4(#[case] server: TestServer, #[case] searched: bool) -> Result<(), Error> { - let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, "😀.bin"))?; + let resp = reqwest::blocking::get(format!("{}{}?q={}", server.url(), DIR_NO_INDEX, BIN_FILE))?; assert_eq!(resp.status(), 200); let paths = utils::retrieve_index_paths(&resp.text()?); - assert!(!paths.is_empty()); - assert_eq!(paths.iter().all(|v| v.contains("😀.bin")), searched); + assert_eq!(paths.iter().all(|v| v.contains(BIN_FILE)), searched); Ok(()) } diff --git a/tests/utils.rs b/tests/utils.rs index f8d4b5a..c40be5e 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -25,24 +25,13 @@ macro_rules! fetch { } #[allow(dead_code)] -pub fn retrieve_index_paths(index: &str) -> IndexSet { - retrieve_index_paths_impl(index).unwrap_or_default() -} - -#[allow(dead_code)] -pub fn encode_uri(v: &str) -> String { - let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect(); - parts.join("/") -} - -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 line_col = line.find("DATA =").unwrap() + 6; - let value: Value = line[line_col..].parse().ok()?; +pub fn retrieve_index_paths(content: &str) -> IndexSet { + let value = retrive_json(content).unwrap(); let paths = value - .get("paths")? - .as_array()? + .get("paths") + .unwrap() + .as_array() + .unwrap() .iter() .flat_map(|v| { let name = v.get("name")?.as_str()?; @@ -54,5 +43,26 @@ fn retrieve_index_paths_impl(index: &str) -> Option> { } }) .collect(); - Some(paths) + paths +} + +#[allow(dead_code)] +pub fn retrive_edit_file(content: &str) -> Option { + let value = retrive_json(content)?; + let value = value.get("editable").unwrap(); + Some(value.as_bool().unwrap()) +} + +#[allow(dead_code)] +pub fn encode_uri(v: &str) -> String { + let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect(); + parts.join("/") +} + +fn retrive_json(content: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let line = lines.iter().find(|v| v.contains("DATA ="))?; + let line_col = line.find("DATA =").unwrap() + 6; + let value: Value = line[line_col..].parse().unwrap(); + Some(value) }