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
16enum ResolvedPath {
21 Markdown(PathBuf),
22 Static(PathBuf),
23}
24
25pub struct MarkdownFiles {
39 mount_path: String,
40 dir: PathBuf,
41 template: String,
42}
43
44impl MarkdownFiles {
45 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 pub fn template(mut self, tmpl: impl Into<String>) -> Self {
62 self.template = tmpl.into();
63 self
64 }
65
66 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
75impl HttpServiceFactory for MarkdownFiles {
80 fn register(self, config: &mut AppService) {
81 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
99struct MarkdownFilesInner {
104 dir: PathBuf,
105 template: String,
106}
107
108pub struct MarkdownFilesService(Arc<MarkdownFilesInner>);
110
111impl 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
146impl 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 if !is_safe(&self.dir, &candidate) {
174 return None;
175 }
176
177 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 if candidate.extension().is_some_and(|e| e == "md") && candidate.is_file() {
188 return Some(ResolvedPath::Markdown(candidate));
189 }
190
191 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 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
239fn is_safe(base: &Path, target: &Path) -> bool {
244 let Ok(canon_base) = base.canonicalize() else {
247 return false;
248 };
249
250 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
276pub 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}