Skip to main content

actix_mark/
service.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::Arc,
4};
5
6use actix_web::{
7    dev::{AppService, HttpServiceFactory, ResourceDef, ServiceRequest, ServiceResponse},
8    http::header,
9    Error, HttpResponse,
10};
11use actix_files::NamedFile;
12use tokio::fs;
13
14use crate::{markdown, template};
15
16// ---------------------------------------------------------------------------
17// Internal path resolution result
18// ---------------------------------------------------------------------------
19
20enum ResolvedPath {
21    Markdown(PathBuf),
22    Static(PathBuf),
23}
24
25// ---------------------------------------------------------------------------
26// Builder
27// ---------------------------------------------------------------------------
28
29/// Actix-web service that serves a directory, rendering `.md` files as HTML.
30///
31/// # Example
32/// ```no_run
33/// use actix_mark::MarkdownFiles;
34///
35/// let svc = MarkdownFiles::new("/docs", "./content")
36///     .template("<html><body><markdown></body></html>");
37/// ```
38pub struct MarkdownFiles {
39    mount_path: String,
40    dir: PathBuf,
41    template: String,
42}
43
44impl MarkdownFiles {
45    /// Create a new `MarkdownFiles` service.
46    ///
47    /// - `mount_path`: the URL prefix (e.g. `"/docs"`)
48    /// - `dir`: the filesystem directory to serve from
49    pub fn new(mount_path: &str, dir: impl Into<PathBuf>) -> Self {
50        Self {
51            mount_path: mount_path.to_string(),
52            dir: dir.into(),
53            template: String::new(),
54        }
55    }
56
57    /// Set the HTML template as a string.
58    ///
59    /// The template must contain a `<markdown>` (or `<markdown/>`) tag which
60    /// will be replaced with the rendered HTML content.
61    pub fn template(mut self, tmpl: impl Into<String>) -> Self {
62        self.template = tmpl.into();
63        self
64    }
65
66    /// Load the HTML template from a file.
67    ///
68    /// The file is read eagerly so errors surface at startup, not at request time.
69    pub fn template_file(mut self, path: impl AsRef<Path>) -> std::io::Result<Self> {
70        self.template = std::fs::read_to_string(path)?;
71        Ok(self)
72    }
73}
74
75// ---------------------------------------------------------------------------
76// HttpServiceFactory
77// ---------------------------------------------------------------------------
78
79impl HttpServiceFactory for MarkdownFiles {
80    fn register(self, config: &mut AppService) {
81        // The pattern ends with `{tail:.*}` to capture everything under the
82        // mount path, including nested directories.
83        let pattern = if self.mount_path.ends_with('/') {
84            format!("{}{}", self.mount_path, "{tail:.*}")
85        } else {
86            format!("{}/{}", self.mount_path, "{tail:.*}")
87        };
88
89        let rdef = ResourceDef::new(pattern);
90        let svc = MarkdownFilesService(Arc::new(MarkdownFilesInner {
91            dir: self.dir,
92            template: self.template,
93        }));
94
95        config.register_service(rdef, None, svc, None);
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Inner service data and newtype service handle
101// ---------------------------------------------------------------------------
102
103struct MarkdownFilesInner {
104    dir: PathBuf,
105    template: String,
106}
107
108/// Newtype so we can implement foreign traits without hitting the orphan rule.
109pub struct MarkdownFilesService(Arc<MarkdownFilesInner>);
110
111// ---------------------------------------------------------------------------
112// actix-web Service + ServiceFactory boilerplate
113// ---------------------------------------------------------------------------
114
115impl actix_web::dev::ServiceFactory<ServiceRequest> for MarkdownFilesService {
116    type Response = ServiceResponse;
117    type Error = Error;
118    type Config = ();
119    type Service = Self;
120    type InitError = ();
121    type Future = std::future::Ready<Result<Self::Service, Self::InitError>>;
122
123    fn new_service(&self, _: ()) -> Self::Future {
124        std::future::ready(Ok(MarkdownFilesService(self.0.clone())))
125    }
126}
127
128impl actix_web::dev::Service<ServiceRequest> for MarkdownFilesService {
129    type Response = ServiceResponse;
130    type Error = Error;
131    type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>>>>;
132
133    fn poll_ready(
134        &self,
135        _: &mut std::task::Context<'_>,
136    ) -> std::task::Poll<Result<(), Self::Error>> {
137        std::task::Poll::Ready(Ok(()))
138    }
139
140    fn call(&self, req: ServiceRequest) -> Self::Future {
141        let inner = self.0.clone();
142        Box::pin(async move { inner.handle(req).await })
143    }
144}
145
146// ---------------------------------------------------------------------------
147// Request handling
148// ---------------------------------------------------------------------------
149
150impl MarkdownFilesInner {
151    async fn handle(&self, req: ServiceRequest) -> Result<ServiceResponse, Error> {
152        let tail = req
153            .match_info()
154            .get("tail")
155            .unwrap_or("")
156            .to_string();
157
158        match self.resolve_path(&tail) {
159            Some(ResolvedPath::Markdown(path)) => self.serve_markdown(req, &path).await,
160            Some(ResolvedPath::Static(path)) => self.serve_static(req, &path),
161            None => {
162                let (req, _) = req.into_parts();
163                Ok(ServiceResponse::new(req, HttpResponse::NotFound().finish()))
164            }
165        }
166    }
167
168    fn resolve_path(&self, tail: &str) -> Option<ResolvedPath> {
169        let rel = tail.trim_start_matches('/');
170        let candidate = self.dir.join(rel);
171
172        // Safety check first — bail if the candidate escapes the base dir.
173        if !is_safe(&self.dir, &candidate) {
174            return None;
175        }
176
177        // Directory → try index.md
178        if candidate.is_dir() {
179            let index = candidate.join("index.md");
180            if index.is_file() {
181                return Some(ResolvedPath::Markdown(index));
182            }
183            return None;
184        }
185
186        // Exact .md file
187        if candidate.extension().is_some_and(|e| e == "md") && candidate.is_file() {
188            return Some(ResolvedPath::Markdown(candidate));
189        }
190
191        // Extension-less path → try appending .md
192        if candidate.extension().is_none() {
193            let with_md = candidate.with_extension("md");
194            if with_md.is_file() {
195                return Some(ResolvedPath::Markdown(with_md));
196            }
197        }
198
199        // Anything else that exists → static file
200        if candidate.is_file() {
201            return Some(ResolvedPath::Static(candidate));
202        }
203
204        None
205    }
206
207    async fn serve_markdown(
208        &self,
209        req: ServiceRequest,
210        path: &Path,
211    ) -> Result<ServiceResponse, Error> {
212        let source = fs::read_to_string(path).await.map_err(|e| {
213            actix_web::error::ErrorInternalServerError(e)
214        })?;
215
216        let html_body = markdown::to_html(&source);
217        let page = template::render(&self.template, &html_body);
218
219        let (req, _) = req.into_parts();
220        let resp = HttpResponse::Ok()
221            .insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8"))
222            .body(page);
223
224        Ok(ServiceResponse::new(req, resp))
225    }
226
227    fn serve_static(
228        &self,
229        req: ServiceRequest,
230        path: &Path,
231    ) -> Result<ServiceResponse, Error> {
232        let file = NamedFile::open(path).map_err(actix_web::error::ErrorInternalServerError)?;
233        let (http_req, _) = req.into_parts();
234        let resp = file.into_response(&http_req);
235        Ok(ServiceResponse::new(http_req, resp))
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Path traversal guard
241// ---------------------------------------------------------------------------
242
243fn is_safe(base: &Path, target: &Path) -> bool {
244    // Canonicalize the base; for the target we build a canonical-like path
245    // without requiring it to exist yet (to handle .md extension probing).
246    let Ok(canon_base) = base.canonicalize() else {
247        return false;
248    };
249
250    // Normalize the target by canonicalizing its existing prefix.
251    // We walk up until we find a component that exists, then re-attach the rest.
252    let mut existing = target.to_path_buf();
253    let mut suffix = vec![];
254    loop {
255        if existing.exists() {
256            break;
257        }
258        match existing.file_name() {
259            Some(name) => {
260                suffix.push(name.to_os_string());
261                existing.pop();
262            }
263            None => return false,
264        }
265    }
266    let Ok(mut canon) = existing.canonicalize() else {
267        return false;
268    };
269    for component in suffix.into_iter().rev() {
270        canon.push(component);
271    }
272
273    canon.starts_with(&canon_base)
274}
275
276// ---------------------------------------------------------------------------
277// URL helpers exposed for the example/tests
278// ---------------------------------------------------------------------------
279
280/// Returns the URL path a browser should use to reach a given `.md` file,
281/// stripping the `.md` extension.
282pub fn md_url(base_url: &str, rel_path: &str) -> String {
283    let stripped = rel_path
284        .strip_suffix(".md")
285        .unwrap_or(rel_path);
286    format!("{}/{}", base_url.trim_end_matches('/'), stripped.trim_start_matches('/'))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn safe_path_within_base() {
295        let base = PathBuf::from(".");
296        let target = PathBuf::from("./src/lib.rs");
297        assert!(is_safe(&base, &target));
298    }
299
300    #[test]
301    fn traversal_is_rejected() {
302        let base = PathBuf::from("./src");
303        let target = PathBuf::from("./src/../../Cargo.toml");
304        assert!(!is_safe(&base, &target));
305    }
306}