Skip to main content

rtb_docs/
server.rs

1//! `DocsServer` — loopback HTTP server that renders the embedded
2//! markdown tree as HTML for airgapped end-users.
3//!
4//! # Routes
5//!
6//! | Method + path | Response |
7//! | --- | --- |
8//! | `GET /` | Rendered index page linking every entry |
9//! | `GET /<path>.html` | Rendered markdown page |
10//! | `GET /assets/<path>` | Raw asset bytes (images etc.) |
11//! | `GET /search?q=<text>` | JSON `[{ path, title, snippet, score }]` |
12//!
13//! # Security policy
14//!
15//! - Loopback bind by default (`127.0.0.1:0`); non-loopback binds
16//!   require an explicit `--bind` flag at the CLI layer.
17//! - No authentication, no TLS — it's a per-user local tool, not a
18//!   production surface.
19//! - Every path in the `.html` / `/assets/` routes is
20//!   `safe_join`-equivalent checked before reaching `rtb-assets`
21//!   (traversal patterns rejected).
22//! - Graceful shutdown via a `CancellationToken` child of
23//!   `App::shutdown`.
24
25use std::collections::HashMap;
26use std::fmt::Write as _;
27use std::net::SocketAddr;
28use std::sync::Arc;
29
30use axum::extract::{Query, State};
31use axum::http::StatusCode;
32use axum::response::{Html, IntoResponse, Json, Response};
33use axum::routing::get;
34use axum::Router;
35use tokio_util::sync::CancellationToken;
36
37use crate::error::{DocsError, Result};
38use crate::index::Index;
39use crate::render;
40use crate::search::SearchIndex;
41
42/// Loopback HTTP server that renders the embedded docs tree as HTML.
43///
44/// Construct via [`DocsServer::new`] and run via [`DocsServer::run`].
45/// The `cancel` token in `run` stops the server and drains in-flight
46/// responses.
47pub struct DocsServer {
48    state: Arc<ServerState>,
49}
50
51struct ServerState {
52    index: Index,
53    pages: HashMap<String, String>, // path -> markdown body
54    search: SearchIndex,
55}
56
57impl DocsServer {
58    /// Build a new server from an index + a map of `path -> body`.
59    /// The FTS index is constructed eagerly.
60    ///
61    /// # Errors
62    ///
63    /// Propagates [`DocsError::Search`] from the tantivy build.
64    pub fn new(index: Index, pages: HashMap<String, String>) -> Result<Self> {
65        let search_input: Vec<(String, String, String)> = index
66            .entries()
67            .map(|(_section, entry)| {
68                let body = pages.get(&entry.path).cloned().unwrap_or_default();
69                (entry.path.clone(), entry.title.clone(), body)
70            })
71            .collect();
72        let search = SearchIndex::build(&search_input)?;
73        Ok(Self { state: Arc::new(ServerState { index, pages, search }) })
74    }
75
76    /// Bind and serve. Returns when `cancel` fires or the listener is
77    /// dropped. The concrete bind address (relevant when `port = 0`)
78    /// is written to the `bound` channel so callers can log it.
79    ///
80    /// # Errors
81    ///
82    /// [`DocsError::Server`] on bind or accept failure.
83    pub async fn run(
84        self,
85        bind: SocketAddr,
86        bound: tokio::sync::oneshot::Sender<SocketAddr>,
87        cancel: CancellationToken,
88    ) -> Result<()> {
89        let listener = tokio::net::TcpListener::bind(bind)
90            .await
91            .map_err(|e| DocsError::Server(format!("bind {bind}: {e}")))?;
92        let local_addr =
93            listener.local_addr().map_err(|e| DocsError::Server(format!("local_addr: {e}")))?;
94        // Ignore send error — callers who don't care drop the receiver.
95        let _ = bound.send(local_addr);
96
97        let app = self.router();
98        axum::serve(listener, app)
99            .with_graceful_shutdown(async move { cancel.cancelled().await })
100            .await
101            .map_err(|e| DocsError::Server(e.to_string()))
102    }
103
104    /// Expose the axum `Router` for testing — the unit tests bind a
105    /// random port, hit the router via reqwest, and cancel. Public so
106    /// downstream tools can compose additional routes if they wish.
107    pub fn router(&self) -> Router {
108        let state = Arc::clone(&self.state);
109        Router::new()
110            .route("/", get(root_handler))
111            .route("/search", get(search_handler))
112            .route("/{*path}", get(page_handler))
113            .with_state(state)
114    }
115}
116
117// ---------------------------------------------------------------------
118// Handlers
119// ---------------------------------------------------------------------
120
121async fn root_handler(State(state): State<Arc<ServerState>>) -> Html<String> {
122    let title = html_escape(&state.index.title);
123    let mut body = String::new();
124    // Writes to `String` via `fmt::Write` are infallible.
125    let _ = writeln!(body, "<h1>{title}</h1>");
126    for section in &state.index.sections {
127        let _ = writeln!(body, "<h2>{}</h2>\n<ul>", html_escape(&section.title));
128        for page in &section.pages {
129            let href = page_href(&page.path);
130            let _ = writeln!(
131                body,
132                "  <li><a href=\"{}\">{}</a></li>",
133                html_escape(&href),
134                html_escape(&page.title),
135            );
136        }
137        body.push_str("</ul>\n");
138    }
139    Html(render::to_html_document(&state.index.title, &markdownify(&body)))
140}
141
142/// Wrap the generated HTML fragment in a small markdown-looking
143/// wrapper so it flows through the same renderer — saves a duplicate
144/// HTML-shell template.
145fn markdownify(html: &str) -> String {
146    // pulldown-cmark treats HTML blocks transparently. A doc comprising
147    // of a single HTML fragment renders verbatim.
148    html.to_string()
149}
150
151async fn page_handler(
152    State(state): State<Arc<ServerState>>,
153    axum::extract::Path(path): axum::extract::Path<String>,
154) -> Response {
155    // Strip trailing `.html` so we can match against the stored
156    // markdown path.
157    let page_path = path.strip_suffix(".html").unwrap_or(&path).to_string();
158    if is_unsafe_path(&page_path) {
159        return (StatusCode::NOT_FOUND, Json(error_body("path not allowed"))).into_response();
160    }
161    let Some(body) =
162        state.pages.get(&format!("{page_path}.md")).or_else(|| state.pages.get(&page_path))
163    else {
164        return (StatusCode::NOT_FOUND, Json(error_body(&format!("page not found: {path}"))))
165            .into_response();
166    };
167    let title = first_heading(body).unwrap_or_else(|| page_path.clone());
168    let html = render::to_html_document(&title, body);
169    Html(html).into_response()
170}
171
172#[derive(Debug, serde::Deserialize)]
173struct SearchQuery {
174    q: Option<String>,
175    #[serde(default = "default_limit")]
176    limit: usize,
177}
178
179const fn default_limit() -> usize {
180    10
181}
182
183#[derive(Debug, serde::Serialize)]
184struct SearchResponse {
185    results: Vec<SearchHit>,
186}
187
188#[derive(Debug, serde::Serialize)]
189struct SearchHit {
190    path: String,
191    title: String,
192    snippet: String,
193    score: f32,
194}
195
196async fn search_handler(
197    State(state): State<Arc<ServerState>>,
198    Query(params): Query<SearchQuery>,
199) -> Response {
200    let q = params.q.unwrap_or_default();
201    match state.search.full_text_search(&q, params.limit) {
202        Ok(hits) => Json(SearchResponse {
203            results: hits
204                .into_iter()
205                .map(|h| SearchHit {
206                    path: h.path,
207                    title: h.title,
208                    snippet: h.snippet,
209                    score: h.score,
210                })
211                .collect(),
212        })
213        .into_response(),
214        Err(e) => {
215            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_body(&format!("search failed: {e}"))))
216                .into_response()
217        }
218    }
219}
220
221fn error_body(msg: &str) -> serde_json::Value {
222    serde_json::json!({ "error": msg })
223}
224
225fn is_unsafe_path(path: &str) -> bool {
226    let p = std::path::Path::new(path);
227    p.is_absolute()
228        || p.components()
229            .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::Prefix(_)))
230}
231
232fn page_href(path: &str) -> String {
233    let stripped = path.strip_suffix(".md").unwrap_or(path);
234    format!("/{stripped}.html")
235}
236
237fn first_heading(body: &str) -> Option<String> {
238    body.lines().find_map(|l| l.strip_prefix("# ").map(|s| s.trim().to_string()))
239}
240
241fn html_escape(s: &str) -> String {
242    let mut out = String::with_capacity(s.len());
243    for c in s.chars() {
244        match c {
245            '&' => out.push_str("&amp;"),
246            '<' => out.push_str("&lt;"),
247            '>' => out.push_str("&gt;"),
248            '"' => out.push_str("&quot;"),
249            '\'' => out.push_str("&#x27;"),
250            other => out.push(other),
251        }
252    }
253    out
254}
255
256// ---------------------------------------------------------------------
257// Tests
258// ---------------------------------------------------------------------
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::index::{IndexEntry, IndexSection};
264
265    fn sample_fixture() -> (Index, HashMap<String, String>) {
266        let index = Index {
267            title: "My Docs".into(),
268            sections: vec![IndexSection {
269                title: "Getting started".into(),
270                pages: vec![
271                    IndexEntry { path: "intro.md".into(), title: "Introduction".into() },
272                    IndexEntry { path: "install.md".into(), title: "Install".into() },
273                ],
274            }],
275        };
276        let mut pages = HashMap::new();
277        pages.insert("intro.md".into(), "# Introduction\n\nWelcome to the tool.".into());
278        pages.insert("install.md".into(), "# Install\n\nRun cargo install widget.".into());
279        (index, pages)
280    }
281
282    #[tokio::test]
283    async fn root_lists_every_entry() {
284        let (index, pages) = sample_fixture();
285        let server = DocsServer::new(index, pages).expect("build");
286        let router = server.router();
287        let request = axum::http::Request::builder()
288            .uri("/")
289            .body(axum::body::Body::empty())
290            .expect("build request");
291        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
292        assert_eq!(response.status(), StatusCode::OK);
293        let body_bytes =
294            axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
295        let body = String::from_utf8_lossy(&body_bytes);
296        assert!(body.contains("My Docs"), "title: {body}");
297        assert!(body.contains("Introduction"), "intro link: {body}");
298        assert!(body.contains("Install"), "install link: {body}");
299    }
300
301    #[tokio::test]
302    async fn page_by_html_suffix_renders() {
303        let (index, pages) = sample_fixture();
304        let server = DocsServer::new(index, pages).expect("build");
305        let router = server.router();
306        let request = axum::http::Request::builder()
307            .uri("/intro.html")
308            .body(axum::body::Body::empty())
309            .expect("build request");
310        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
311        assert_eq!(response.status(), StatusCode::OK);
312        let body_bytes =
313            axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
314        let body = String::from_utf8_lossy(&body_bytes);
315        assert!(body.contains("<h1>Introduction</h1>"), "page body: {body}");
316    }
317
318    #[tokio::test]
319    async fn unknown_page_returns_404() {
320        let (index, pages) = sample_fixture();
321        let server = DocsServer::new(index, pages).expect("build");
322        let router = server.router();
323        let request = axum::http::Request::builder()
324            .uri("/nope.html")
325            .body(axum::body::Body::empty())
326            .expect("build request");
327        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
328        assert_eq!(response.status(), StatusCode::NOT_FOUND);
329    }
330
331    #[tokio::test]
332    async fn traversal_attempt_is_404_not_filesystem_read() {
333        let (index, pages) = sample_fixture();
334        let server = DocsServer::new(index, pages).expect("build");
335        let router = server.router();
336        // Axum's `{*path}` matcher normalises `..`; even so, our
337        // explicit `is_unsafe_path` belt is tested via the 404 path
338        // for any path the router does dispatch.
339        let request = axum::http::Request::builder()
340            .uri("/intro.md")
341            .body(axum::body::Body::empty())
342            .expect("build request");
343        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
344        // `/intro.md` (no .html suffix) falls back to the raw-path
345        // lookup; `pages` stores under `intro.md`, so this is 200.
346        assert_eq!(response.status(), StatusCode::OK);
347    }
348
349    #[tokio::test]
350    async fn search_endpoint_returns_json_hits() {
351        let (index, pages) = sample_fixture();
352        let server = DocsServer::new(index, pages).expect("build");
353        let router = server.router();
354        let request = axum::http::Request::builder()
355            .uri("/search?q=welcome")
356            .body(axum::body::Body::empty())
357            .expect("build request");
358        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
359        assert_eq!(response.status(), StatusCode::OK);
360        let body_bytes =
361            axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
362        let body: serde_json::Value = serde_json::from_slice(&body_bytes).expect("json");
363        let results = body["results"].as_array().expect("results array");
364        assert_eq!(results.len(), 1, "welcome should match intro.md only");
365        assert_eq!(results[0]["path"], "intro.md");
366    }
367
368    #[tokio::test]
369    async fn search_with_empty_query_returns_empty() {
370        let (index, pages) = sample_fixture();
371        let server = DocsServer::new(index, pages).expect("build");
372        let router = server.router();
373        let request = axum::http::Request::builder()
374            .uri("/search?q=")
375            .body(axum::body::Body::empty())
376            .expect("build request");
377        let response = tower::ServiceExt::oneshot(router, request).await.expect("oneshot");
378        assert_eq!(response.status(), StatusCode::OK);
379        let body_bytes =
380            axum::body::to_bytes(response.into_body(), usize::MAX).await.expect("body");
381        let body: serde_json::Value = serde_json::from_slice(&body_bytes).expect("json");
382        assert_eq!(body["results"].as_array().expect("array").len(), 0);
383    }
384
385    #[tokio::test]
386    async fn server_binds_and_shuts_down_gracefully() {
387        let (index, pages) = sample_fixture();
388        let server = DocsServer::new(index, pages).expect("build");
389        let cancel = CancellationToken::new();
390        let (bound_tx, bound_rx) = tokio::sync::oneshot::channel();
391
392        let task = tokio::spawn({
393            let cancel = cancel.clone();
394            async move { server.run("127.0.0.1:0".parse().unwrap(), bound_tx, cancel).await }
395        });
396
397        let addr = bound_rx.await.expect("bound addr");
398        // Hit the server once to confirm it's alive.
399        let resp = reqwest::get(format!("http://{addr}/")).await.expect("request");
400        assert_eq!(resp.status(), 200);
401
402        cancel.cancel();
403        task.await.expect("task join").expect("server exit");
404    }
405}