From 6d9758c71d2827bfcc2c36bdf1bd29c2cfb9b773 Mon Sep 17 00:00:00 2001 From: sigoden Date: Tue, 21 Feb 2023 12:42:40 +0800 Subject: [PATCH] feat: ui improves the login experience (#182) close #157 #158 --- assets/index.css | 5 +++- assets/index.html | 27 +++++++++++++---- assets/index.js | 74 +++++++++++++++++++++++++++++++++++++++-------- src/auth.rs | 7 +++++ src/server.rs | 71 +++++++++++++++++++++++++++++++++++---------- tests/auth.rs | 14 +++++++++ 6 files changed, 164 insertions(+), 34 deletions(-) diff --git a/assets/index.css b/assets/index.css index ae2efb4..7f89a7e 100644 --- a/assets/index.css +++ b/assets/index.css @@ -208,9 +208,12 @@ body { outline: none; } -.save-btn { +.toolbox2 { margin-left: auto; margin-right: 2em; +} + +.save-btn { cursor: pointer; user-select: none; } diff --git a/assets/index.html b/assets/index.html index 6694f35..fee290c 100644 --- a/assets/index.html +++ b/assets/index.html @@ -72,12 +72,27 @@ -
diff --git a/assets/index.js b/assets/index.js index 4349c38..28ff54c 100644 --- a/assets/index.js +++ b/assets/index.js @@ -16,6 +16,8 @@ * @property {boolean} allow_delete * @property {boolean} allow_search * @property {boolean} allow_archive + * @property {boolean} auth + * @property {string} user * @property {boolean} dir_exists * @property {string} editable */ @@ -71,6 +73,10 @@ let $emptyFolder; * @type Element */ let $editor; +/** + * @type Element + */ +let $userBtn; function ready() { $pathsTable = document.querySelector(".paths-table") @@ -79,6 +85,7 @@ function ready() { $uploadersTable = document.querySelector(".uploaders-table"); $emptyFolder = document.querySelector(".empty-folder"); $editor = document.querySelector(".editor"); + $userBtn = document.querySelector(".user-btn"); addBreadcrumb(DATA.href, DATA.uri_prefix); @@ -86,6 +93,10 @@ function ready() { document.title = `Index of ${DATA.href} - Dufs`; document.querySelector(".index-page").classList.remove("hidden"); + if (DATA.auth) { + setupAuth(); + } + if (DATA.allow_search) { setupSearch() } @@ -106,7 +117,6 @@ function ready() { document.title = `Edit of ${DATA.href} - Dufs`; document.querySelector(".editor-page").classList.remove("hidden");; - setupEditor(); } } @@ -203,16 +213,22 @@ Uploader.globalIdx = 0; Uploader.runnings = 0; +Uploader.auth = false; + /** * @type Uploader[] */ Uploader.queues = []; -Uploader.runQueue = () => { +Uploader.runQueue = async () => { if (Uploader.runnings > 2) return; let uploader = Uploader.queues.shift(); if (!uploader) return; + if (!Uploader.auth) { + Uploader.auth = true; + await login(); + } uploader.ajax(); } @@ -365,7 +381,7 @@ function addPath(file, index) { ${getPathSvg(file.path_type)} - ${encodedName} + ${encodedName} ${formatMtime(file.mtime)} ${formatSize(file.size).join(" ")} @@ -385,10 +401,11 @@ async function deletePath(index) { if (!confirm(`Delete \`${file.name}\`?`)) return; try { + await login(); const res = await fetch(newUrl(file.name), { method: "DELETE", }); - await assertFetch(res); + await assertResOK(res); document.getElementById(`addPath${index}`).remove(); DATA.paths[index] = null; if (!DATA.paths.find(v => !!v)) { @@ -425,14 +442,15 @@ async function movePath(index) { const newFileUrl = fileUrlObj.origin + prefix + newPath.split("/").map(encodeURIComponent).join("/"); try { + await login(); const res = await fetch(fileUrl, { method: "MOVE", headers: { "Destination": newFileUrl, } }); - await assertFetch(res); - location.href = newFileUrl.split("/").slice(0, -1).join("/") + await assertResOK(res); + location.href = newFileUrl.split("/").slice(0, -1).join("/"); } catch (err) { alert(`Cannot move \`${filePath}\` to \`${newPath}\`, ${err.message}`); } @@ -445,7 +463,7 @@ function dropzone() { e.stopPropagation(); }); }); - document.addEventListener("drop", e => { + document.addEventListener("drop", async e => { if (!e.dataTransfer.items[0].webkitGetAsEntry) { const files = e.dataTransfer.files.filter(v => v.size > 0); for (const file of files) { @@ -462,6 +480,18 @@ function dropzone() { }); } +function setupAuth() { + if (DATA.user) { + $userBtn.classList.remove("hidden"); + $userBtn.title = DATA.user; + } else { + const $loginBtn = document.querySelector(".login-btn"); + $loginBtn.classList.remove("hidden"); + $loginBtn.addEventListener("click", () => login(true)); + } +} + + /** * Setup searchbar */ @@ -491,7 +521,7 @@ function setupUpload() { if (name) createFolder(name); }); document.querySelector(".upload-file").classList.remove("hidden"); - document.getElementById("file").addEventListener("change", e => { + document.getElementById("file").addEventListener("change", async e => { const files = e.target.files; for (let file of files) { new Uploader(file, []).upload(); @@ -527,7 +557,7 @@ async function setupEditor() { $editor.classList.remove("hidden"); try { const res = await fetch(baseUrl()); - await assertFetch(res); + await assertResOK(res); const text = await res.text(); $editor.value = text; } catch (err) { @@ -549,6 +579,24 @@ async function saveChange() { } } +async function login(alert = false) { + if (!DATA.auth) return; + try { + const res = await fetch(baseUrl() + "?auth"); + await assertResOK(res); + document.querySelector(".login-btn").classList.add("hidden"); + $userBtn.classList.remove("hidden"); + $userBtn.title = ""; + } catch (err) { + let message = `Cannot login, ${err.message}`; + if (alert) { + alert(message); + } else { + throw new Error(message); + } + } +} + /** * Create a folder * @param {string} name @@ -556,10 +604,11 @@ async function saveChange() { async function createFolder(name) { const url = newUrl(name); try { + await login(); const res = await fetch(url, { method: "MKCOL", }); - await assertFetch(res); + await assertResOK(res); location.href = url; } catch (err) { alert(`Cannot create folder \`${name}\`, ${err.message}`); @@ -569,11 +618,12 @@ async function createFolder(name) { async function createFile(name) { const url = newUrl(name); try { + await login(); const res = await fetch(url, { method: "PUT", body: "", }); - await assertFetch(res); + await assertResOK(res); location.href = url + "?edit"; } catch (err) { alert(`Cannot create file \`${name}\`, ${err.message}`); @@ -663,7 +713,7 @@ function encodedStr(rawStr) { }); } -async function assertFetch(res) { +async function assertResOK(res) { if (!(res.status >= 200 && res.status < 300)) { throw new Error(await res.text()) } diff --git a/src/auth.rs b/src/auth.rs index e19bc16..cf51ce6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -72,6 +72,10 @@ impl AccessControl { Ok(Self { rules }) } + pub fn valid(&self) -> bool { + !self.rules.is_empty() + } + pub fn guard( &self, path: &str, @@ -134,6 +138,9 @@ impl GuardType { pub fn is_reject(&self) -> bool { *self == GuardType::Reject } + pub fn is_readwrite(&self) -> bool { + *self == GuardType::ReadWrite + } } fn sanitize_path(path: &str, uri_prefix: &str) -> String { diff --git a/src/server.rs b/src/server.rs index d5e7b09..fa37d75 100644 --- a/src/server.rs +++ b/src/server.rs @@ -144,6 +144,18 @@ impl Server { return Ok(res); } + 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(); + + if query_params.contains_key("auth") { + if !guard_type.is_readwrite() { + self.auth_reject(&mut res); + } + return Ok(res); + } + let head_only = method == Method::HEAD; if self.args.path_is_file { @@ -170,11 +182,6 @@ 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()), None => (true, false, false, 0), @@ -204,21 +211,32 @@ impl Server { } self.handle_zip_dir(path, head_only, &mut res).await?; } else if allow_search && query_params.contains_key("q") { - self.handle_search_dir(path, &query_params, head_only, &mut res) + let user = self.retrieve_user(authorization); + self.handle_search_dir(path, &query_params, head_only, user, &mut res) .await?; } else { + let user = self.retrieve_user(authorization); self.handle_render_index( path, &query_params, headers, head_only, + user, &mut res, ) .await?; } } else if render_index || render_spa { - self.handle_render_index(path, &query_params, headers, head_only, &mut res) - .await?; + let user = self.retrieve_user(authorization); + self.handle_render_index( + path, + &query_params, + headers, + head_only, + user, + &mut res, + ) + .await?; } else if query_params.contains_key("zip") { if !allow_archive { status_not_found(&mut res); @@ -226,15 +244,19 @@ impl Server { } self.handle_zip_dir(path, head_only, &mut res).await?; } else if allow_search && query_params.contains_key("q") { - self.handle_search_dir(path, &query_params, head_only, &mut res) + let user = self.retrieve_user(authorization); + self.handle_search_dir(path, &query_params, head_only, user, &mut res) .await?; } else { - self.handle_ls_dir(path, true, &query_params, head_only, &mut res) + let user = self.retrieve_user(authorization); + self.handle_ls_dir(path, true, &query_params, head_only, user, &mut res) .await?; } } else if is_file { if query_params.contains_key("edit") { - self.handle_edit_file(path, head_only, &mut res).await?; + let user = self.retrieve_user(authorization); + self.handle_edit_file(path, head_only, user, &mut res) + .await?; } else { self.handle_send_file(path, headers, head_only, &mut res) .await?; @@ -243,7 +265,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, &query_params, head_only, &mut res) + let user = self.retrieve_user(authorization); + self.handle_ls_dir(path, false, &query_params, head_only, user, &mut res) .await?; } else { status_not_found(&mut res); @@ -382,6 +405,7 @@ impl Server { exist: bool, query_params: &HashMap, head_only: bool, + user: Option, res: &mut Response, ) -> BoxResult<()> { let mut paths = vec![]; @@ -394,7 +418,7 @@ impl Server { } } }; - self.send_index(path, paths, exist, query_params, head_only, res) + self.send_index(path, paths, exist, query_params, head_only, user, res) } async fn handle_search_dir( @@ -402,6 +426,7 @@ impl Server { path: &Path, query_params: &HashMap, head_only: bool, + user: Option, res: &mut Response, ) -> BoxResult<()> { let mut paths: Vec = vec![]; @@ -452,7 +477,7 @@ impl Server { } } } - self.send_index(path, paths, true, query_params, head_only, res) + self.send_index(path, paths, true, query_params, head_only, user, res) } async fn handle_zip_dir( @@ -495,6 +520,7 @@ impl Server { query_params: &HashMap, headers: &HeaderMap, head_only: bool, + user: Option, res: &mut Response, ) -> BoxResult<()> { let index_path = path.join(INDEX_NAME); @@ -507,7 +533,7 @@ 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, query_params, head_only, res) + self.handle_ls_dir(path, true, query_params, head_only, user, res) .await?; } else { status_not_found(res) @@ -682,6 +708,7 @@ impl Server { &self, path: &Path, head_only: bool, + user: Option, res: &mut Response, ) -> BoxResult<()> { let (file, meta) = tokio::join!(fs::File::open(path), fs::metadata(path),); @@ -696,6 +723,8 @@ impl Server { uri_prefix: self.args.uri_prefix.clone(), allow_upload: self.args.allow_upload, allow_delete: self.args.allow_delete, + auth: self.args.auth.valid(), + user, editable, }; res.headers_mut() @@ -842,6 +871,7 @@ impl Server { Ok(()) } + #[allow(clippy::too_many_arguments)] fn send_index( &self, path: &Path, @@ -849,6 +879,7 @@ impl Server { exist: bool, query_params: &HashMap, head_only: bool, + user: Option, res: &mut Response, ) -> BoxResult<()> { if let Some(sort) = query_params.get("sort") { @@ -903,6 +934,8 @@ impl Server { allow_search: self.args.allow_search, allow_archive: self.args.allow_archive, dir_exists: exist, + auth: self.args.auth.valid(), + user, paths, }; let output = if query_params.contains_key("json") { @@ -1057,6 +1090,10 @@ impl Server { size, })) } + + fn retrieve_user(&self, authorization: Option<&HeaderValue>) -> Option { + self.args.auth_method.get_user(authorization?) + } } #[derive(Debug, Serialize)] @@ -1075,6 +1112,8 @@ struct IndexData { allow_search: bool, allow_archive: bool, dir_exists: bool, + auth: bool, + user: Option, paths: Vec, } @@ -1085,6 +1124,8 @@ struct EditData { uri_prefix: String, allow_upload: bool, allow_delete: bool, + auth: bool, + user: Option, editable: bool, } diff --git a/tests/auth.rs b/tests/auth.rs index 83b6434..39e5757 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -45,6 +45,20 @@ fn auth_skip_on_options_method( Ok(()) } +#[rstest] +fn auth_check( + #[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer, +) -> Result<(), Error> { + let url = format!("{}index.html?auth", server.url()); + let resp = fetch!(b"GET", &url).send()?; + assert_eq!(resp.status(), 401); + let resp = fetch!(b"GET", &url).send_with_digest_auth("user2", "pass2")?; + assert_eq!(resp.status(), 401); + let resp = fetch!(b"GET", &url).send_with_digest_auth("user", "pass")?; + assert_eq!(resp.status(), 200); + Ok(()) +} + #[rstest] fn auth_readonly( #[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,