Skip to main content

sova_static/
lib.rs

1//! Static file routes as a regular [`Plugin`] — public `Router::get` + conditional headers.
2
3mod serve;
4
5use sova_core::extend::normalize_path;
6use sova_core::{App, Plugin, Request, Router};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11/// Mount a directory behind Express-style routes (`mount` + `mount/*path`).
12pub struct Static {
13    mount: String,
14    dir: PathBuf,
15    max_age: Duration,
16    max_age_explicit: bool,
17    immutable: bool,
18    allow_dotfiles: bool,
19    index: bool,
20}
21
22impl Static {
23    pub fn new(mount: impl Into<String>, dir: impl Into<PathBuf>) -> Self {
24        let mut mount = normalize_path(&mount.into());
25        while mount.len() > 1 && mount.ends_with('/') {
26            mount.pop();
27        }
28        Self {
29            mount,
30            dir: dir.into(),
31            max_age: Duration::from_secs(3600),
32            max_age_explicit: false,
33            immutable: false,
34            allow_dotfiles: false,
35            index: true,
36        }
37    }
38
39    /// `Cache-Control: public, max-age=N` (default 1 hour).
40    pub fn max_age(mut self, max_age: Duration) -> Self {
41        self.max_age = max_age;
42        self.max_age_explicit = true;
43        self
44    }
45
46    /// Append `immutable` to Cache-Control.
47    pub fn immutable(mut self, on: bool) -> Self {
48        self.immutable = on;
49        self
50    }
51
52    /// Allow paths with `.`-prefixed segments (default: deny → 403).
53    pub fn dotfiles_allow(mut self) -> Self {
54        self.allow_dotfiles = true;
55        self
56    }
57
58    /// Serve `index.html` for the mount path (default true).
59    pub fn index(mut self, on: bool) -> Self {
60        self.index = on;
61        self
62    }
63
64    /// Register on any [`Router`]. Module middleware is applied when that router is mounted.
65    pub fn register(self, router: &mut Router) {
66        let opts = Arc::new(serve::StaticOpts {
67            max_age: self.max_age,
68            immutable: self.immutable,
69            allow_dotfiles: self.allow_dotfiles,
70        });
71        let dir = Arc::new(self.dir);
72        let mount = self.mount;
73
74        if self.index {
75            let dir_index = Arc::clone(&dir);
76            let opts_index = Arc::clone(&opts);
77            router.get(&mount, move |req: Request| {
78                let dir = Arc::clone(&dir_index);
79                let opts = Arc::clone(&opts_index);
80                async move {
81                    serve::serve_in(
82                        dir.as_path(),
83                        Path::new("index.html"),
84                        serve::FileOptions::from_request(&req),
85                        &opts,
86                    )
87                    .await
88                }
89            });
90        }
91
92        let wildcard = if mount == "/" {
93            "/*path".to_string()
94        } else {
95            format!("{mount}/*path")
96        };
97
98        let dir_files = Arc::clone(&dir);
99        router.get(&wildcard, move |req: Request| {
100            let dir = Arc::clone(&dir_files);
101            let opts = Arc::clone(&opts);
102            async move {
103                let rel = req
104                    .param("path")
105                    .map(PathBuf::from)
106                    .unwrap_or_else(|| PathBuf::from("index.html"));
107                serve::serve_in(
108                    dir.as_path(),
109                    &rel,
110                    serve::FileOptions::from_request(&req),
111                    &opts,
112                )
113                .await
114            }
115        });
116    }
117}
118
119impl Plugin for Static {
120    fn id(&self) -> &'static str {
121        "static"
122    }
123
124    fn meta(&self) -> sova_core::PluginMeta {
125        sova_core::PluginMeta::new("Static files")
126            .description("Serve files from a directory under a mount path")
127            .version(env!("CARGO_PKG_VERSION"))
128    }
129
130    fn install(mut self, app: &mut App) {
131        if !self.max_age_explicit {
132            if let Some(doc) = app.config_doc() {
133                if let Some(section) = doc.section("static") {
134                    if let Some(s) = section.get("max_age").and_then(|v| v.as_str()) {
135                        if let Ok(d) = sova_core::extend::parse_duration(s) {
136                            self.max_age = d;
137                        }
138                    } else if let Some(n) = section.get("max_age").and_then(|v| v.as_integer()) {
139                        self.max_age = Duration::from_secs(n as u64);
140                    }
141                }
142            }
143        }
144        self.register(app);
145    }
146}