use std::path::{Path, PathBuf};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'?')
.add(b'[')
.add(b']')
.add(b'{')
.add(b'}');
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileServerConfig {
mount_path: String,
mounts: Vec<FileMount>,
branding: Branding,
theme: Theme,
features: FeatureFlags,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileMount {
pub id: String,
pub name: String,
pub root_dir: PathBuf,
}
impl FileMount {
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
root_dir: impl Into<PathBuf>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
root_dir: root_dir.into(),
}
}
}
impl FileServerConfig {
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
Self {
mount_path: "/files".to_string(),
mounts: vec![FileMount::new(
"default",
"Files and folders",
root_dir.into(),
)],
branding: Branding::default(),
theme: Theme::default(),
features: FeatureFlags::default(),
}
}
pub fn mount_path(&self) -> &str {
&self.mount_path
}
pub fn root_dir(&self) -> &Path {
&self.mounts[0].root_dir
}
pub fn mounts(&self) -> &[FileMount] {
&self.mounts
}
pub fn default_mount(&self) -> &FileMount {
&self.mounts[0]
}
pub fn mount(&self, mount_id: &str) -> Option<&FileMount> {
self.mounts.iter().find(|mount| mount.id == mount_id)
}
pub fn branding(&self) -> &Branding {
&self.branding
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn features(&self) -> &FeatureFlags {
&self.features
}
pub fn route_paths(&self) -> RoutePaths {
RoutePaths::new(&self.mount_path)
}
pub fn with_mount_path(mut self, mount_path: impl Into<String>) -> Self {
self.mount_path = normalize_mount_path(&mount_path.into());
self
}
pub fn with_branding(mut self, branding: Branding) -> Self {
self.branding = branding;
self
}
pub fn with_mounts(mut self, mounts: Vec<FileMount>) -> Self {
assert!(
!mounts.is_empty(),
"FileServerConfig requires at least one mount"
);
self.mounts = mounts;
self
}
pub fn with_mount(mut self, mount: FileMount) -> Self {
self.mounts.push(mount);
self
}
pub fn with_theme(mut self, theme: Theme) -> Self {
self.theme = theme;
self
}
pub fn with_features(mut self, features: FeatureFlags) -> Self {
self.features = features;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Branding {
pub title: String,
pub tagline: Option<String>,
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
}
impl Branding {
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_tagline(mut self, tagline: impl Into<String>) -> Self {
self.tagline = Some(tagline.into());
self
}
pub fn with_logo_url(mut self, logo_url: impl Into<String>) -> Self {
self.logo_url = Some(logo_url.into());
self
}
pub fn with_favicon_url(mut self, favicon_url: impl Into<String>) -> Self {
self.favicon_url = Some(favicon_url.into());
self
}
}
impl Default for Branding {
fn default() -> Self {
Self {
title: "Slash Files".to_string(),
tagline: Some("A polished, mountable file browser for Rust backends.".to_string()),
logo_url: None,
favicon_url: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Theme {
pub background: String,
pub surface: String,
pub surface_elevated: String,
pub text: String,
pub muted_text: String,
pub accent: String,
pub accent_text: String,
pub danger: String,
pub border: String,
pub radius: String,
}
impl Default for Theme {
fn default() -> Self {
Self {
background: "#0b1020".to_string(),
surface: "#121a2d".to_string(),
surface_elevated: "#1a2440".to_string(),
text: "#eef2ff".to_string(),
muted_text: "#94a3b8".to_string(),
accent: "#7c3aed".to_string(),
accent_text: "#f8fafc".to_string(),
danger: "#ef4444".to_string(),
border: "#23314f".to_string(),
radius: "18px".to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FeatureFlags {
pub enable_search: bool,
pub enable_preview: bool,
pub enable_delete: bool,
pub enable_download: bool,
pub enable_move: bool,
pub enable_batch_actions: bool,
}
impl Default for FeatureFlags {
fn default() -> Self {
Self {
enable_search: true,
enable_preview: true,
enable_delete: true,
enable_download: true,
enable_move: true,
enable_batch_actions: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoutePaths {
pub mount_path: String,
pub api_root: String,
pub api_mounts: String,
pub api_entries: String,
pub api_search: String,
pub api_storage: String,
pub api_delete_selected: String,
pub api_download_selected: String,
pub api_move_selected: String,
pub browse: String,
pub search: String,
pub preview: String,
pub raw: String,
pub delete_selected: String,
pub download_selected: String,
pub download_jobs: String,
pub move_selected: String,
pub move_jobs: String,
pub static_htmx_js: String,
pub static_styles_css: String,
}
impl RoutePaths {
pub fn new(mount_path: &str) -> Self {
let mount_path = normalize_mount_path(mount_path);
Self {
api_root: join_mount_path(&mount_path, "api"),
api_mounts: join_mount_path(&mount_path, "api/mounts"),
api_entries: join_mount_path(&mount_path, "api/entries"),
api_search: join_mount_path(&mount_path, "api/search"),
api_storage: join_mount_path(&mount_path, "api/storage"),
api_delete_selected: join_mount_path(&mount_path, "api/delete-selected"),
api_download_selected: join_mount_path(&mount_path, "api/download-selected"),
api_move_selected: join_mount_path(&mount_path, "api/move-selected"),
browse: join_mount_path(&mount_path, "browse"),
search: join_mount_path(&mount_path, "search"),
preview: join_mount_path(&mount_path, "preview"),
raw: join_mount_path(&mount_path, "raw"),
delete_selected: join_mount_path(&mount_path, "delete-selected"),
download_selected: join_mount_path(&mount_path, "download-selected"),
download_jobs: join_mount_path(&mount_path, "download-selected/jobs"),
move_selected: join_mount_path(&mount_path, "move-selected"),
move_jobs: join_mount_path(&mount_path, "move-selected/jobs"),
static_htmx_js: join_mount_path(&mount_path, "static/htmx.min.js"),
static_styles_css: join_mount_path(&mount_path, "static/styles.css"),
mount_path,
}
}
pub fn raw_file_url(&self, relative_path: &str) -> String {
self.raw_file_url_for_mount("", relative_path)
}
pub fn raw_file_url_for_mount(&self, mount_id: &str, relative_path: &str) -> String {
if relative_path.is_empty() {
self.raw.clone()
} else {
let encoded_path = relative_path
.split('/')
.map(|segment| utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string())
.collect::<Vec<_>>()
.join("/");
let base = format!("{}/{}", self.raw, encoded_path);
if mount_id.is_empty() {
base
} else {
format!("{base}?mount={}", urlencoding::encode(mount_id))
}
}
}
pub fn download_job_status_url(&self, job_id: &str) -> String {
format!("{}/{job_id}/status", self.download_jobs)
}
pub fn download_job_file_url(&self, job_id: &str) -> String {
format!("{}/{job_id}/file", self.download_jobs)
}
pub fn move_job_status_url(&self, job_id: &str) -> String {
format!("{}/{job_id}/status", self.move_jobs)
}
}
fn normalize_mount_path(mount_path: &str) -> String {
let trimmed = mount_path.trim();
if trimmed.is_empty() || trimmed == "/" {
return "/".to_string();
}
let stripped = trimmed.trim_matches('/');
format!("/{stripped}")
}
fn join_mount_path(mount_path: &str, suffix: &str) -> String {
if mount_path == "/" {
format!("/{}", suffix.trim_start_matches('/'))
} else {
format!("{mount_path}/{}", suffix.trim_start_matches('/'))
}
}
#[cfg(test)]
mod tests {
use super::{FileMount, FileServerConfig, RoutePaths};
use std::path::Path;
#[test]
fn normalizes_mount_path_variants() {
let config = FileServerConfig::new(".").with_mount_path("files/");
assert_eq!(config.mount_path(), "/files");
assert_eq!(config.root_dir(), Path::new("."));
}
#[test]
fn supports_configuring_multiple_mounts() {
let config = FileServerConfig::new(".").with_mounts(vec![
FileMount::new("data", "Data", "."),
FileMount::new("archive", "Archive", "./server"),
]);
assert_eq!(config.default_mount().id, "data");
assert_eq!(config.mount("archive").unwrap().name, "Archive");
}
#[test]
fn supports_root_mount_path() {
let routes = RoutePaths::new("/");
assert_eq!(routes.mount_path, "/");
assert_eq!(routes.api_mounts, "/api/mounts");
assert_eq!(routes.browse, "/browse");
assert_eq!(routes.static_styles_css, "/static/styles.css");
}
}