aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSven-Hendrik Haase <svenstaro@gmail.com>2025-03-07 11:38:10 +0000
committerGitHub <noreply@github.com>2025-03-07 11:38:10 +0000
commit419204c291273e073cb4e1049655bea848dbc441 (patch)
tree341cb7ac4bd5915deb8fe58947b3cc352687556d
parentReformat style.scss (diff)
parentAdd asynchronous directory size counting (diff)
downloadminiserve-419204c291273e073cb4e1049655bea848dbc441.tar.gz
miniserve-419204c291273e073cb4e1049655bea848dbc441.zip
Merge pull request #1482 from svenstaro/add-asynchronous-directory-size-loading
Add asynchronous directory size counting
-rw-r--r--CHANGELOG.md1
-rw-r--r--Cargo.lock125
-rw-r--r--Cargo.toml3
-rw-r--r--data/style.scss19
-rw-r--r--src/args.rs2
-rw-r--r--src/auth.rs7
-rw-r--r--src/config.rs10
-rw-r--r--src/errors.rs3
-rw-r--r--src/file_op.rs34
-rw-r--r--src/listing.rs16
-rw-r--r--src/main.rs45
-rw-r--r--src/renderer.rs81
-rw-r--r--tests/api.rs53
-rw-r--r--tests/serve_request.rs3
14 files changed, 376 insertions, 26 deletions
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
@@ -464,6 +464,46 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -475,6 +515,17 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -544,6 +595,19 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -760,6 +824,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1051,6 +1124,27 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1194,6 +1288,19 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2059,6 +2166,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"assert_fs",
+ "async-walkdir",
"bytesize",
"chrono",
"chrono-humanize",
@@ -2247,6 +2355,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2400,6 +2514,17 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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
{
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,