diff options
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 { |