Skip to main content

slash_files/
config.rs

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}