Repository: svenstaro/miniserve (rust)

Issue:
Size of directories is not calculated
![image](https://user-images.githubusercontent.com/36224762/81748620-66a56080-94bf-11ea-9acc-51b32f0f64fa.png)


Pull request:
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.

Fixes #306 and supersedes #965.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be38ce3..f030e29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Add upload progress bar and allow for multiple concurrent file uploads [#1431](https://github.com/svenstaro/miniserve/pull/1431) (thanks @AlecDivito)
 - Add `--size-display` to allow for toggling file size display between `human` and `exact` [#1261](https://github.com/svenstaro/miniserve/pull/1261) (thanks @Lzzzzzt)
 - Add well-known healthcheck route at `/__miniserve_internal/healthcheck` (of `/<prefix>/__miniserve_internal/healthcheck` when using `--route-prefix`)
+- Add asynchronous recursive directory size counting [#1482](https://github.com/svenstaro/miniserve/pull/1482)
 
 ## [0.29.0] - 2025-02-06
 - Make URL encoding fully WHATWG-compliant [#1454](https://github.com/svenstaro/miniserve/pull/1454) (thanks @cyqsimon)
diff --git a/Cargo.lock b/Cargo.lock
index 0a96e96..e744911 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -463,6 +463,46 @@ dependencies = [
  "tempfile",
 ]
 
+[[package]]
+name = "async-channel"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-fs"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
 [[package]]
 name = "async-trait"
 version = "0.1.86"
@@ -474,6 +514,17 @@ dependencies = [
  "syn 2.0.98",
 ]
 
+[[package]]
+name = "async-walkdir"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37672978ae0febce7516ae0a85b53e6185159a9a28787391eb63fc44ec36037d"
+dependencies = [
+ "async-fs",
+ "futures-lite",
+ "thiserror 2.0.11",
+]
+
 [[package]]
 name = "atomic-waker"
 version = "1.1.2"
@@ -543,6 +594,19 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "blocking"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
 [[package]]
 name = "brotli"
 version = "6.0.0"
@@ -759,6 +823,15 @@ dependencies = [
  "unicode_categories",
 ]
 
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "convert_case"
 version = "0.4.0"
@@ -1050,6 +1123,27 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "event-listener"
+version = "5.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "fake-tty"
 version = "0.3.1"
@@ -1193,6 +1287,19 @@ version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
 
+[[package]]
+name = "futures-lite"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "futures-macro"
 version = "0.3.31"
@@ -2059,6 +2166,7 @@ dependencies = [
  "anyhow",
  "assert_cmd",
  "assert_fs",
+ "async-walkdir",
  "bytesize",
  "chrono",
  "chrono-humanize",
@@ -2246,6 +2354,12 @@ dependencies = [
  "vcpkg",
 ]
 
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
 [[package]]
 name = "parking_lot"
 version = "0.12.3"
@@ -2399,6 +2513,17 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "piper"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
 [[package]]
 name = "pkg-config"
 version = "0.3.31"
diff --git a/Cargo.toml b/Cargo.toml
index 1525849..1115fef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ actix-web = { version = "4", features = ["macros", "compress-brotli", "compress-
 actix-web-httpauth = "0.8"
 alphanumeric-sort = "1"
 anyhow = "1"
+async-walkdir = "2.1.0"
 bytesize = "2"
 chrono = "0.4"
 chrono-humanize = "0.2"
@@ -74,7 +75,7 @@ assert_fs = "1"
 predicates = "3"
 pretty_assertions = "1.2"
 regex = "1"
-reqwest = { version = "0.12", features = ["blocking", "multipart", "rustls-tls"], default-features = false }
+reqwest = { version = "0.12", features = ["blocking", "multipart", "json", "rustls-tls"], default-features = false }
 reqwest_dav = "0.1"
 rstest = "0.24"
 select = "0.6"
diff --git a/data/style.scss b/data/style.scss
index ea80754..44c3752 100644
--- a/data/style.scss
+++ b/data/style.scss
@@ -428,6 +428,25 @@ td.date-cell {
   justify-content: space-between;
 }
 
+.loading-indicator {
+  width: 65%;
+  height: 15px;
+  margin-left: 35%;
+  animation: shimmer 2s infinite;
+  background: linear-gradient(to right, #e6e6e655 5%, #77777755 25%, #e6e6e655 35%);
+  background-size: 500px 100%;
+}
+
+@keyframes shimmer {
+  from {
+    background-position: -500px 0;
+  }
+
+  to {
+    background-position: 500px 0;
+  }
+}
+
 .history {
   color: var(--date_text_color);
 }
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
             {


Test patch:
diff --git a/tests/api.rs b/tests/api.rs
new file mode 100644
index 0000000..32d6cef
--- /dev/null
+++ b/tests/api.rs
@@ -0,0 +1,53 @@
+use std::collections::HashMap;
+
+use reqwest::{StatusCode, blocking::Client};
+use rstest::rstest;
+
+mod fixtures;
+
+use crate::fixtures::{DIRECTORIES, Error, TestServer, server};
+
+#[rstest]
+fn api_dir_size(server: TestServer) -> Result<(), Error> {
+    let mut command = HashMap::new();
+    command.insert("DirSize", DIRECTORIES[0]);
+
+    let resp = Client::new()
+        .post(server.url().join(&format!("__miniserve_internal/api"))?)
+        .json(&command)
+        .send()?
+        .error_for_status()?;
+
+    assert_eq!(resp.status(), StatusCode::OK);
+    assert_ne!(resp.text()?, "0 B");
+
+    Ok(())
+}
+
+/// Test for path traversal vulnerability (CWE-22) in DirSize parameter.
+#[rstest]
+#[case("/tmp")] // Not CWE-22, but `foo` isn't a directory
+#[case("/../foo")]
+#[case("../foo")]
+#[case("../tmp")]
+#[case("/tmp")]
+#[case("/foo")]
+#[case("C:/foo")]
+#[case(r"C:\foo")]
+#[case(r"\foo")]
+fn api_dir_size_prevent_path_transversal_attacks(
+    server: TestServer,
+    #[case] path: &str,
+) -> Result<(), Error> {
+    let mut command = HashMap::new();
+    command.insert("DirSize", path);
+
+    let resp = Client::new()
+        .post(server.url().join(&format!("__miniserve_internal/api"))?)
+        .json(&command)
+        .send()?;
+
+    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+
+    Ok(())
+}
diff --git a/tests/serve_request.rs b/tests/serve_request.rs
index d9d4880..36bdbe5 100644
--- a/tests/serve_request.rs
+++ b/tests/serve_request.rs
@@ -86,6 +86,9 @@ fn serves_requests_with_non_default_port(server: TestServer) -> Result<(), Error
 #[case("__miniserve_internal/healthcheck", server(&["--random-route"]))]
 #[case("__miniserve_internal/favicon.svg", server(&["--random-route"]))]
 #[case("__miniserve_internal/style.css", server(&["--random-route"]))]
+#[case("__miniserve_internal/healthcheck", server(&["--auth", "doesnt:matter"]))]
+#[case("__miniserve_internal/favicon.svg", server(&["--auth", "doesnt:matter"]))]
+#[case("__miniserve_internal/style.css", server(&["--auth", "doesnt:matter"]))]
 fn serves_requests_for_special_routes(
     #[case] route: &str,
     #[case] server: TestServer,