diff options
author | Sven-Hendrik Haase <svenstaro@gmail.com> | 2025-03-07 10:00:48 +0000 |
---|---|---|
committer | Sven-Hendrik Haase <svenstaro@gmail.com> | 2025-03-07 11:14:03 +0000 |
commit | 11ea8a19d1481b0660e5a2765da6e67d3e8aa72c (patch) | |
tree | 341cb7ac4bd5915deb8fe58947b3cc352687556d /src | |
parent | Reformat style.scss (diff) | |
download | miniserve-11ea8a19d1481b0660e5a2765da6e67d3e8aa72c.tar.gz miniserve-11ea8a19d1481b0660e5a2765da6e67d3e8aa72c.zip |
Add asynchronous directory size counting
This is enabled by default and without an option to toggle it off as it's asynchronous and shouldn't
block the server thread.
Diffstat (limited to 'src')
-rw-r--r-- | src/args.rs | 2 | ||||
-rw-r--r-- | src/auth.rs | 7 | ||||
-rw-r--r-- | src/config.rs | 10 | ||||
-rw-r--r-- | src/errors.rs | 3 | ||||
-rw-r--r-- | src/file_op.rs | 34 | ||||
-rw-r--r-- | src/listing.rs | 16 | ||||
-rw-r--r-- | src/main.rs | 45 | ||||
-rw-r--r-- | src/renderer.rs | 81 |
8 files changed, 173 insertions, 25 deletions
diff --git a/src/args.rs b/src/args.rs index 6874e25..d078ef2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -367,7 +367,7 @@ pub struct CliArgs { #[arg(long, env = "MINISERVE_ENABLE_WEBDAV", conflicts_with = "no_symlinks")] pub enable_webdav: bool, - /// Show served file size in exact bytes. + /// Show served file size in exact bytes #[arg(long, default_value_t = SizeDisplay::Human, env = "MINISERVE_SIZE_DISPLAY")] pub size_display: SizeDisplay, } diff --git a/src/auth.rs b/src/auth.rs index fa28c4a..3bd9313 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpMessage, dev::ServiceRequest}; +use actix_web::{HttpMessage, dev::ServiceRequest, web}; use actix_web_httpauth::extractors::basic::BasicAuth; use sha2::{Digest, Sha256, Sha512}; @@ -77,7 +77,10 @@ pub async fn handle_auth( req: ServiceRequest, cred: BasicAuth, ) -> actix_web::Result<ServiceRequest, (actix_web::Error, ServiceRequest)> { - let required_auth = &req.app_data::<crate::MiniserveConfig>().unwrap().auth; + let required_auth = &req + .app_data::<web::Data<crate::MiniserveConfig>>() + .unwrap() + .auth; req.extensions_mut().insert(CurrentUser { name: cred.user_id().to_string(), diff --git a/src/config.rs b/src/config.rs index efec515..eecc45e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,7 +24,7 @@ const ROUTE_ALPHABET: [char; 16] = [ '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', ]; -#[derive(Clone)] +#[derive(Debug, Clone)] /// Configuration of the Miniserve application pub struct MiniserveConfig { /// Enable verbose mode @@ -66,6 +66,9 @@ pub struct MiniserveConfig { /// Well-known healthcheck route (prefixed if route_prefix is provided) pub healthcheck_route: String, + /// Well-known API route (prefixed if route_prefix is provided) + pub api_route: String, + /// Well-known favicon route (prefixed if route_prefix is provided) pub favicon_route: String, @@ -206,15 +209,17 @@ impl MiniserveConfig { // If --random-route is enabled, in order to not leak the random generated route, we must not use it // as static files prefix. // Otherwise, we should apply route_prefix to static files. - let (healthcheck_route, favicon_route, css_route) = if args.random_route { + let (healthcheck_route, api_route, favicon_route, css_route) = if args.random_route { ( "/__miniserve_internal/healthcheck".into(), + "/__miniserve_internal/api".into(), "/__miniserve_internal/favicon.svg".into(), "/__miniserve_internal/style.css".into(), ) } else { ( format!("{}/{}", route_prefix, "__miniserve_internal/healthcheck"), + format!("{}/{}", route_prefix, "__miniserve_internal/api"), format!("{}/{}", route_prefix, "__miniserve_internal/favicon.svg"), format!("{}/{}", route_prefix, "__miniserve_internal/style.css"), ) @@ -305,6 +310,7 @@ impl MiniserveConfig { default_sorting_order: args.default_sorting_order, route_prefix, healthcheck_route, + api_route, favicon_route, css_route, default_color_scheme, diff --git a/src/errors.rs b/src/errors.rs index e35e8a8..3ac7da2 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use actix_web::{ dev::{ResponseHead, ServiceRequest, ServiceResponse}, http::{StatusCode, header}, middleware::Next, + web, }; use thiserror::Error; @@ -159,7 +160,7 @@ fn map_error_page(req: &HttpRequest, head: &mut ResponseHead, body: BoxBody) -> _ => return BoxBody::new(error_msg), }; - let conf = req.app_data::<MiniserveConfig>().unwrap(); + let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap(); let return_address = req .headers() .get(header::REFERER) diff --git a/src/file_op.rs b/src/file_op.rs index 4319410..149cd2a 100644 --- a/src/file_op.rs +++ b/src/file_op.rs @@ -4,6 +4,7 @@ use std::io::ErrorKind; use std::path::{Component, Path, PathBuf}; use actix_web::{HttpRequest, HttpResponse, http::header, web}; +use async_walkdir::{Filtering, WalkDir}; use futures::{StreamExt, TryStreamExt}; use log::{info, warn}; use serde::Deserialize; @@ -38,6 +39,37 @@ impl FileHash { } } +/// Get the recursively calculated dir size for a given dir +/// +/// Expects `dir` to be sanitized. This function doesn't do any sanitization itself. +pub async fn recursive_dir_size(dir: &Path) -> Result<u64, RuntimeError> { + let mut entries = WalkDir::new(dir).filter(|entry| async move { + if let Ok(metadata) = entry.metadata().await { + if metadata.is_file() { + return Filtering::Continue; + } + } + Filtering::Ignore + }); + + let mut total_size = 0; + loop { + match entries.next().await { + Some(Ok(entry)) => { + if let Ok(metadata) = entry.metadata().await { + total_size += metadata.len(); + } + } + Some(Err(e)) => { + warn!("Error trying to read file when calculating dir size: {e}"); + return Err(RuntimeError::InvalidPathError(e.to_string())); + } + None => break, + } + } + Ok(total_size) +} + /// Saves file data from a multipart form field (`field`) to `file_path`. Optionally overwriting /// existing file and comparing the uploaded file checksum to the user provided `file_hash`. /// @@ -329,7 +361,7 @@ pub async fn upload_file( query: web::Query<FileOpQueryParameters>, payload: web::Payload, ) -> Result<HttpResponse, RuntimeError> { - let conf = req.app_data::<MiniserveConfig>().unwrap(); + let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap(); let upload_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| { RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string()) })?; diff --git a/src/listing.rs b/src/listing.rs index 025ae86..6e50ba1 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -4,7 +4,7 @@ use std::path::{Component, Path}; use std::time::SystemTime; use actix_web::{ - HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web::Query, + HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web, web::Query, }; use bytesize::ByteSize; use clap::ValueEnum; @@ -51,7 +51,7 @@ pub struct ListingQueryParameters { } /// Available sorting methods -#[derive(Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] +#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum SortingMethod { @@ -67,7 +67,7 @@ pub enum SortingMethod { } /// Available sorting orders -#[derive(Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] +#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)] pub enum SortingOrder { /// Ascending order #[serde(alias = "asc")] @@ -81,8 +81,9 @@ pub enum SortingOrder { Desc, } -#[derive(PartialEq, Eq)] /// Possible entry types +#[derive(PartialEq, Clone, Display, Eq)] +#[strum(serialize_all = "snake_case")] pub enum EntryType { /// Entry is a directory Directory, @@ -158,7 +159,10 @@ impl Breadcrumb { } pub async fn file_handler(req: HttpRequest) -> actix_web::Result<actix_files::NamedFile> { - let path = &req.app_data::<crate::MiniserveConfig>().unwrap().path; + let path = &req + .app_data::<web::Data<crate::MiniserveConfig>>() + .unwrap() + .path; actix_files::NamedFile::open(path).map_err(Into::into) } @@ -171,7 +175,7 @@ pub fn directory_listing( let extensions = req.extensions(); let current_user: Option<&CurrentUser> = extensions.get::<CurrentUser>(); - let conf = req.app_data::<crate::MiniserveConfig>().unwrap(); + let conf = req.app_data::<web::Data<crate::MiniserveConfig>>().unwrap(); if conf.disable_indexing { return Ok(ServiceResponse::new( req.clone(), diff --git a/src/main.rs b/src/main.rs index 856d22d..adda3f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use actix_web::{ }; use actix_web_httpauth::middleware::HttpAuthentication; use anyhow::Result; +use bytesize::ByteSize; use clap::{CommandFactory, Parser, crate_version}; use colored::*; use dav_server::{ @@ -21,7 +22,8 @@ use dav_server::{ actix::{DavRequest, DavResponse}, }; use fast_qr::QRBuilder; -use log::{error, warn}; +use log::{error, info, warn}; +use serde::Deserialize; mod archive; mod args; @@ -38,6 +40,7 @@ mod webdav_fs; use crate::config::MiniserveConfig; use crate::errors::{RuntimeError, StartupError}; +use crate::file_op::recursive_dir_size; use crate::webdav_fs::RestrictedFs; static STYLESHEET: &str = grass::include!("data/style.scss"); @@ -215,7 +218,7 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { let srv = actix_web::HttpServer::new(move || { App::new() .wrap(configure_header(&inside_config.clone())) - .app_data(inside_config.clone()) + .app_data(web::Data::new(inside_config.clone())) .app_data(stylesheet.clone()) .wrap(from_fn(errors::error_page_middleware)) .wrap(middleware::Logger::default()) @@ -224,6 +227,7 @@ async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> { middleware::Compress::default(), )) .route(&inside_config.healthcheck_route, web::get().to(healthcheck)) + .route(&inside_config.api_route, web::post().to(api)) .route(&inside_config.favicon_route, web::get().to(favicon)) .route(&inside_config.css_route, web::get().to(css)) .service( @@ -353,7 +357,7 @@ fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) { files = files.default_handler(fn_service(|req: ServiceRequest| async { let (req, _) = req.into_parts(); let conf = req - .app_data::<MiniserveConfig>() + .app_data::<web::Data<MiniserveConfig>>() .expect("Could not get miniserve config"); let mut path_base = req.path()[1..].to_string(); if path_base.ends_with('/') { @@ -438,6 +442,41 @@ async fn healthcheck() -> impl Responder { HttpResponse::Ok().body("OK") } +#[derive(Deserialize, Debug)] +enum ApiCommand { + /// Request the size of a particular directory + DirSize(String), +} + +/// This "API" is pretty shitty but frankly miniserve doesn't really need a very fancy API. Or at +/// least I hope so. +async fn api( + command: web::Json<ApiCommand>, + config: web::Data<MiniserveConfig>, +) -> Result<impl Responder, RuntimeError> { + match command.into_inner() { + ApiCommand::DirSize(dir) => { + // Convert the relative dir to an absolute path on the system + let sanitized_path = + file_utils::sanitize_path(&dir, true).expect("Expected a path to directory"); + let full_path = config + .path + .canonicalize() + .expect("Couldn't canonicalize path") + .join(sanitized_path); + info!("Requested directory listing for {full_path:?}"); + + let dir_size = recursive_dir_size(&full_path).await?; + if config.show_exact_bytes { + Ok(format!("{dir_size} B")) + } else { + let dir_size = ByteSize::b(dir_size); + Ok(dir_size.to_string()) + } + } + } +} + async fn favicon() -> impl Responder { let logo = include_str!("../data/logo.svg"); HttpResponse::Ok() diff --git a/src/renderer.rs b/src/renderer.rs index a59f458..d6b01c8 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -52,7 +52,7 @@ pub fn page( html! { (DOCTYPE) html { - (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) + (page_header(&title_path, conf.file_upload, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body #drop-container { @@ -525,11 +525,12 @@ fn entry_row( show_exact_bytes: bool, ) -> Markup { html! { - tr { + @let entry_type = entry.entry_type.clone(); + tr .{ "entry-type-" (entry_type) } { td { p { @if entry.is_dir() { - @if let Some(symlink_dest) = entry.symlink_info { + @if let Some(ref symlink_dest) = entry.symlink_info { a.symlink href=(parametrized_link(&entry.link, sort_method, sort_order, raw)) { (entry.name) "/" span.symlink-symbol { } @@ -541,7 +542,7 @@ fn entry_row( } } } @else if entry.is_file() { - @if let Some(symlink_dest) = entry.symlink_info { + @if let Some(ref symlink_dest) = entry.symlink_info { a.symlink href=(&entry.link) { (entry.name) span.symlink-symbol { } @@ -624,6 +625,7 @@ fn page_header( title: &str, file_upload: bool, web_file_concurrency: usize, + api_route: &str, favicon_route: &str, css_route: &str, ) -> Markup { @@ -639,8 +641,8 @@ fn page_header( title { (title) } - (PreEscaped(r#" - <script> + script { + (PreEscaped(r#" // updates the color scheme by setting the theme data attribute // on body and saving the new theme to local storage function updateColorScheme(name) { @@ -663,8 +665,69 @@ fn page_header( addEventListener("load", loadColorScheme); // load saved theme when local storage is changed (synchronize between tabs) addEventListener("storage", loadColorScheme); - </script> - "#)) + "#)) + } + + script { + (format!("const API_ROUTE = '{api_route}';")) + (PreEscaped(r#" + let dirSizeCache = {}; + + // Query the directory size from the miniserve API + function fetchDirSize(dir) { + return fetch(API_ROUTE, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + method: 'POST', + body: JSON.stringify({ + DirSize: dir + }) + }).then(resp => resp.text()) + } + + // Initialize shimmer effects for .size-cell elements in .entry-type-directory rows + // + // TODO: Perhaps it'd be better to statically do this during html generation in + // entry_row()? + function initializeLoadingIndicators() { + const directoryCells = document.querySelectorAll('tr.entry-type-directory .size-cell'); + + directoryCells.forEach(cell => { + // Add a loading indicator to each cell + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'loading-indicator'; + cell.appendChild(loadingIndicator); + }); + } + + function updateSizeCells() { + const directoryCells = document.querySelectorAll('tr.entry-type-directory .size-cell'); + + directoryCells.forEach(cell => { + // Get the dir from the sibling anchor tag. + const href = cell.parentNode.querySelector('a').href; + const target = new URL(href).pathname; + + // First check our local cache + if (target in dirSizeCache) { + cell.innerHTML = ''; + cell.textContent = dirSizeCache[target]; + } else { + fetchDirSize(target).then(dir_size => { + cell.innerHTML = ''; + cell.textContent = dir_size; + dirSizeCache[target] = dir_size; + }) + .catch(error => console.error("Error fetching dir size:", error)); + } + }) + } + setInterval(updateSizeCells, 1000); + window.addEventListener('load', initializeLoadingIndicators); + "#)) + } @if file_upload { script { @@ -1003,7 +1066,7 @@ pub fn render_error( html! { (DOCTYPE) html { - (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.favicon_route, &conf.css_route)) + (page_header(&error_code.to_string(), false, conf.web_upload_concurrency, &conf.api_route, &conf.favicon_route, &conf.css_route)) body { |