1use std::path::{Path, PathBuf};
2
3use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
4
5const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
6 .add(b' ')
7 .add(b'"')
8 .add(b'#')
9 .add(b'%')
10 .add(b'?')
11 .add(b'[')
12 .add(b']')
13 .add(b'{')
14 .add(b'}');
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct FileServerConfig {
18 mount_path: String,
19 mounts: Vec<FileMount>,
20 branding: Branding,
21 theme: Theme,
22 features: FeatureFlags,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct FileMount {
27 pub id: String,
28 pub name: String,
29 pub root_dir: PathBuf,
30}
31
32impl FileMount {
33 pub fn new(
34 id: impl Into<String>,
35 name: impl Into<String>,
36 root_dir: impl Into<PathBuf>,
37 ) -> Self {
38 Self {
39 id: id.into(),
40 name: name.into(),
41 root_dir: root_dir.into(),
42 }
43 }
44}
45
46impl FileServerConfig {
47 pub fn new(root_dir: impl Into<PathBuf>) -> Self {
48 Self {
49 mount_path: "/files".to_string(),
50 mounts: vec![FileMount::new(
51 "default",
52 "Files and folders",
53 root_dir.into(),
54 )],
55 branding: Branding::default(),
56 theme: Theme::default(),
57 features: FeatureFlags::default(),
58 }
59 }
60
61 pub fn mount_path(&self) -> &str {
62 &self.mount_path
63 }
64
65 pub fn root_dir(&self) -> &Path {
66 &self.mounts[0].root_dir
67 }
68
69 pub fn mounts(&self) -> &[FileMount] {
70 &self.mounts
71 }
72
73 pub fn default_mount(&self) -> &FileMount {
74 &self.mounts[0]
75 }
76
77 pub fn mount(&self, mount_id: &str) -> Option<&FileMount> {
78 self.mounts.iter().find(|mount| mount.id == mount_id)
79 }
80
81 pub fn branding(&self) -> &Branding {
82 &self.branding
83 }
84
85 pub fn theme(&self) -> &Theme {
86 &self.theme
87 }
88
89 pub fn features(&self) -> &FeatureFlags {
90 &self.features
91 }
92
93 pub fn route_paths(&self) -> RoutePaths {
94 RoutePaths::new(&self.mount_path)
95 }
96
97 pub fn with_mount_path(mut self, mount_path: impl Into<String>) -> Self {
98 self.mount_path = normalize_mount_path(&mount_path.into());
99 self
100 }
101
102 pub fn with_branding(mut self, branding: Branding) -> Self {
103 self.branding = branding;
104 self
105 }
106
107 pub fn with_mounts(mut self, mounts: Vec<FileMount>) -> Self {
108 assert!(
109 !mounts.is_empty(),
110 "FileServerConfig requires at least one mount"
111 );
112 self.mounts = mounts;
113 self
114 }
115
116 pub fn with_mount(mut self, mount: FileMount) -> Self {
117 self.mounts.push(mount);
118 self
119 }
120
121 pub fn with_theme(mut self, theme: Theme) -> Self {
122 self.theme = theme;
123 self
124 }
125
126 pub fn with_features(mut self, features: FeatureFlags) -> Self {
127 self.features = features;
128 self
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct Branding {
134 pub title: String,
135 pub tagline: Option<String>,
136 pub logo_url: Option<String>,
137 pub favicon_url: Option<String>,
138}
139
140impl Branding {
141 pub fn with_title(mut self, title: impl Into<String>) -> Self {
142 self.title = title.into();
143 self
144 }
145
146 pub fn with_tagline(mut self, tagline: impl Into<String>) -> Self {
147 self.tagline = Some(tagline.into());
148 self
149 }
150
151 pub fn with_logo_url(mut self, logo_url: impl Into<String>) -> Self {
152 self.logo_url = Some(logo_url.into());
153 self
154 }
155
156 pub fn with_favicon_url(mut self, favicon_url: impl Into<String>) -> Self {
157 self.favicon_url = Some(favicon_url.into());
158 self
159 }
160}
161
162impl Default for Branding {
163 fn default() -> Self {
164 Self {
165 title: "Slash Files".to_string(),
166 tagline: Some("A polished, mountable file browser for Rust backends.".to_string()),
167 logo_url: None,
168 favicon_url: None,
169 }
170 }
171}
172
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct Theme {
175 pub background: String,
176 pub surface: String,
177 pub surface_elevated: String,
178 pub text: String,
179 pub muted_text: String,
180 pub accent: String,
181 pub accent_text: String,
182 pub danger: String,
183 pub border: String,
184 pub radius: String,
185}
186
187impl Default for Theme {
188 fn default() -> Self {
189 Self {
190 background: "#0b1020".to_string(),
191 surface: "#121a2d".to_string(),
192 surface_elevated: "#1a2440".to_string(),
193 text: "#eef2ff".to_string(),
194 muted_text: "#94a3b8".to_string(),
195 accent: "#7c3aed".to_string(),
196 accent_text: "#f8fafc".to_string(),
197 danger: "#ef4444".to_string(),
198 border: "#23314f".to_string(),
199 radius: "18px".to_string(),
200 }
201 }
202}
203
204#[derive(Clone, Debug, PartialEq, Eq)]
205pub struct FeatureFlags {
206 pub enable_search: bool,
207 pub enable_preview: bool,
208 pub enable_delete: bool,
209 pub enable_download: bool,
210 pub enable_move: bool,
211 pub enable_batch_actions: bool,
212}
213
214impl Default for FeatureFlags {
215 fn default() -> Self {
216 Self {
217 enable_search: true,
218 enable_preview: true,
219 enable_delete: true,
220 enable_download: true,
221 enable_move: true,
222 enable_batch_actions: true,
223 }
224 }
225}
226
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct RoutePaths {
229 pub mount_path: String,
230 pub api_root: String,
231 pub api_mounts: String,
232 pub api_entries: String,
233 pub api_search: String,
234 pub api_storage: String,
235 pub api_delete_selected: String,
236 pub api_download_selected: String,
237 pub api_move_selected: String,
238 pub browse: String,
239 pub search: String,
240 pub preview: String,
241 pub raw: String,
242 pub delete_selected: String,
243 pub download_selected: String,
244 pub download_jobs: String,
245 pub move_selected: String,
246 pub move_jobs: String,
247 pub static_htmx_js: String,
248 pub static_styles_css: String,
249}
250
251impl RoutePaths {
252 pub fn new(mount_path: &str) -> Self {
253 let mount_path = normalize_mount_path(mount_path);
254
255 Self {
256 api_root: join_mount_path(&mount_path, "api"),
257 api_mounts: join_mount_path(&mount_path, "api/mounts"),
258 api_entries: join_mount_path(&mount_path, "api/entries"),
259 api_search: join_mount_path(&mount_path, "api/search"),
260 api_storage: join_mount_path(&mount_path, "api/storage"),
261 api_delete_selected: join_mount_path(&mount_path, "api/delete-selected"),
262 api_download_selected: join_mount_path(&mount_path, "api/download-selected"),
263 api_move_selected: join_mount_path(&mount_path, "api/move-selected"),
264 browse: join_mount_path(&mount_path, "browse"),
265 search: join_mount_path(&mount_path, "search"),
266 preview: join_mount_path(&mount_path, "preview"),
267 raw: join_mount_path(&mount_path, "raw"),
268 delete_selected: join_mount_path(&mount_path, "delete-selected"),
269 download_selected: join_mount_path(&mount_path, "download-selected"),
270 download_jobs: join_mount_path(&mount_path, "download-selected/jobs"),
271 move_selected: join_mount_path(&mount_path, "move-selected"),
272 move_jobs: join_mount_path(&mount_path, "move-selected/jobs"),
273 static_htmx_js: join_mount_path(&mount_path, "static/htmx.min.js"),
274 static_styles_css: join_mount_path(&mount_path, "static/styles.css"),
275 mount_path,
276 }
277 }
278
279 pub fn raw_file_url(&self, relative_path: &str) -> String {
280 self.raw_file_url_for_mount("", relative_path)
281 }
282
283 pub fn raw_file_url_for_mount(&self, mount_id: &str, relative_path: &str) -> String {
284 if relative_path.is_empty() {
285 self.raw.clone()
286 } else {
287 let encoded_path = relative_path
288 .split('/')
289 .map(|segment| utf8_percent_encode(segment, PATH_SEGMENT_ENCODE_SET).to_string())
290 .collect::<Vec<_>>()
291 .join("/");
292
293 let base = format!("{}/{}", self.raw, encoded_path);
294 if mount_id.is_empty() {
295 base
296 } else {
297 format!("{base}?mount={}", urlencoding::encode(mount_id))
298 }
299 }
300 }
301
302 pub fn download_job_status_url(&self, job_id: &str) -> String {
303 format!("{}/{job_id}/status", self.download_jobs)
304 }
305
306 pub fn download_job_file_url(&self, job_id: &str) -> String {
307 format!("{}/{job_id}/file", self.download_jobs)
308 }
309
310 pub fn move_job_status_url(&self, job_id: &str) -> String {
311 format!("{}/{job_id}/status", self.move_jobs)
312 }
313}
314
315fn normalize_mount_path(mount_path: &str) -> String {
316 let trimmed = mount_path.trim();
317
318 if trimmed.is_empty() || trimmed == "/" {
319 return "/".to_string();
320 }
321
322 let stripped = trimmed.trim_matches('/');
323 format!("/{stripped}")
324}
325
326fn join_mount_path(mount_path: &str, suffix: &str) -> String {
327 if mount_path == "/" {
328 format!("/{}", suffix.trim_start_matches('/'))
329 } else {
330 format!("{mount_path}/{}", suffix.trim_start_matches('/'))
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::{FileMount, FileServerConfig, RoutePaths};
337 use std::path::Path;
338
339 #[test]
340 fn normalizes_mount_path_variants() {
341 let config = FileServerConfig::new(".").with_mount_path("files/");
342
343 assert_eq!(config.mount_path(), "/files");
344 assert_eq!(config.root_dir(), Path::new("."));
345 }
346
347 #[test]
348 fn supports_configuring_multiple_mounts() {
349 let config = FileServerConfig::new(".").with_mounts(vec![
350 FileMount::new("data", "Data", "."),
351 FileMount::new("archive", "Archive", "./server"),
352 ]);
353
354 assert_eq!(config.default_mount().id, "data");
355 assert_eq!(config.mount("archive").unwrap().name, "Archive");
356 }
357
358 #[test]
359 fn supports_root_mount_path() {
360 let routes = RoutePaths::new("/");
361
362 assert_eq!(routes.mount_path, "/");
363 assert_eq!(routes.api_mounts, "/api/mounts");
364 assert_eq!(routes.browse, "/browse");
365 assert_eq!(routes.static_styles_css, "/static/styles.css");
366 }
367}