Skip to main content

amberfork_server/
lib.rs

1//! The serving edge: a loopback-only HTTP server over the layout [`Document`].
2//!
3//! This crate is where `tokio` lives (design doc: engine crates stay sync + pure; the
4//! runtime is quarantined to I/O edges). It exposes exactly ONE content endpoint (D12) and
5//! binds 127.0.0.1 only, guarded against DNS rebinding (D6). The document is a snapshot:
6//! serialized once at bind time, then served as immutable bytes — re-polls are answered
7//! with a strong `ETag`/304 pair, which is all the UI's disconnect detection needs.
8
9use amberfork_layout::{Document, PayloadResponse, SlotAddress, ViewModel};
10use axum::Router;
11use axum::body::Bytes;
12use axum::extract::{Json, Request, State};
13use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
14use axum::middleware::{self, Next};
15use axum::response::{IntoResponse, Response};
16use axum::routing::{get, post};
17use rust_embed::{EmbeddedFile, RustEmbed};
18use sha2::{Digest, Sha256};
19use std::borrow::Cow;
20use std::fmt;
21use std::io;
22use std::net::{Ipv4Addr, SocketAddr};
23use std::sync::Arc;
24use tokio::net::TcpListener;
25
26/// The one content endpoint (D12): the versioned view-model document, whole.
27pub const DOCUMENT_ROUTE: &str = "/api/document";
28
29/// Expand-on-demand (issue #30): `POST` a [`SlotAddress`] taken from a truncated slot on the
30/// document, get its full text back. A second route rather than a query string on the first —
31/// a `SlotAddress`'s field-diff path can contain arbitrary characters, and it already has a
32/// `Serialize`/`Deserialize` impl the web painter can round-trip verbatim without inventing an
33/// encoding of its own.
34pub const PAYLOAD_ROUTE: &str = "/api/payload";
35
36/// The app shell every SPA route lands on; its presence is what makes a bundle a bundle.
37const INDEX_HTML: &str = "index.html";
38
39/// The web UI bundle (D7: one embed site, owned by this crate — D13). Empty in a dev
40/// checkout — the contents are .gitignored, built by the release workflow (#26/#28), and
41/// carried into the crates.io package by this crate's `include` list.
42#[derive(RustEmbed)]
43#[folder = "ui-dist"]
44struct UiAssets;
45
46/// The document snapshot on the wire: serialized once, hashed once, shared by every request.
47struct Content {
48    body: Bytes,
49    etag: HeaderValue,
50}
51
52/// Everything a request handler can reach: the served (truncated) snapshot, plus the
53/// pre-envelope view it was truncated from — the one thing [`Document`] itself no longer
54/// carries, and the only way [`serve_payload`] can answer an expand-on-demand request.
55struct ServerState {
56    content: Content,
57    full_view: ViewModel,
58}
59
60/// A bound-but-not-yet-serving server: bind and serve are split so the caller can print
61/// the real URL (OS-assigned port included) before the accept loop starts.
62#[derive(Debug)]
63pub struct Server {
64    listener: TcpListener,
65    router: Router,
66    local_addr: SocketAddr,
67}
68
69/// Everything that can go wrong at the serving edge, typed so the CLI can phrase each case.
70#[derive(Debug)]
71pub enum ServeError {
72    /// Binding the loopback listener failed — in practice: the port is already taken.
73    Bind { addr: SocketAddr, source: io::Error },
74    /// The accept loop died after a successful bind.
75    Serve(io::Error),
76    /// The embedded web bundle has no `index.html` — a dev build without built UI assets.
77    BundleMissing,
78}
79
80impl fmt::Display for ServeError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::Bind { addr, source } => write!(f, "cannot bind {addr}: {source}"),
84            Self::Serve(source) => write!(f, "server stopped: {source}"),
85            Self::BundleMissing => write!(
86                f,
87                "web UI bundle missing — this build has no index.html embedded; build the \
88                 web UI into crates/amberfork-server/ui-dist/ first (release artifacts ship \
89                 with it built in)"
90            ),
91        }
92    }
93}
94
95impl std::error::Error for ServeError {
96    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
97        match self {
98            Self::Bind { source, .. } | Self::Serve(source) => Some(source),
99            Self::BundleMissing => None,
100        }
101    }
102}
103
104impl Server {
105    /// Bind `127.0.0.1:port` (`0` = OS-assigned) around a snapshot of `document`, answering
106    /// expand-on-demand requests against `full_view` — the same [`ViewModel`] `document` was
107    /// built from, before its payload envelope truncated anything. The caller keeps that
108    /// pre-envelope copy around (typically a clone taken right before [`Document::new`]); this
109    /// crate cannot reconstruct it from `document` alone, since truncation is destructive.
110    ///
111    /// Loopback is not a default, it is the only path: no widen flag exists in this crate
112    /// (D6 — traces carry prompts, tool args, and whatever secrets leaked into them, so
113    /// "local, no account" must be literally true).
114    ///
115    /// # Errors
116    /// [`ServeError::Bind`] when the loopback bind fails (port in use).
117    pub async fn bind(
118        document: &Document,
119        full_view: &ViewModel,
120        port: u16,
121    ) -> Result<Self, ServeError> {
122        Self::bind_with_assets::<UiAssets>(document, full_view, port).await
123    }
124
125    /// [`bind`](Self::bind), generic over the embedded bundle — the seam that lets tests
126    /// (and any embedder) supply their own assets.
127    ///
128    /// # Errors
129    /// [`ServeError::Bind`] when the loopback bind fails; [`ServeError::BundleMissing`]
130    /// (checked BEFORE binding — a startup failure must never leave a port claimed) when
131    /// the bundle has no `index.html`.
132    pub async fn bind_with_assets<A: RustEmbed + 'static>(
133        document: &Document,
134        full_view: &ViewModel,
135        port: u16,
136    ) -> Result<Self, ServeError> {
137        if A::get(INDEX_HTML).is_none() {
138            return Err(ServeError::BundleMissing);
139        }
140        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
141        let listener = TcpListener::bind(addr)
142            .await
143            .map_err(|source| ServeError::Bind { addr, source })?;
144        let local_addr = listener
145            .local_addr()
146            .map_err(|source| ServeError::Bind { addr, source })?;
147        Ok(Self {
148            listener,
149            router: router::<A>(document, full_view),
150            local_addr,
151        })
152    }
153
154    /// The address actually bound — the one true source for the URL the CLI prints.
155    pub fn local_addr(&self) -> SocketAddr {
156        self.local_addr
157    }
158
159    /// Run the accept loop until the future is dropped.
160    ///
161    /// # Errors
162    /// [`ServeError::Serve`] if the accept loop fails after a successful bind.
163    pub async fn serve(self) -> Result<(), ServeError> {
164        axum::serve(self.listener, self.router)
165            .await
166            .map_err(ServeError::Serve)
167    }
168}
169
170fn router<A: RustEmbed + 'static>(document: &Document, full_view: &ViewModel) -> Router {
171    let json = serde_json::to_string(document)
172        .expect("Document serialization is infallible (no non-string map keys)");
173    let etag = HeaderValue::from_str(&format!("\"{:x}\"", Sha256::digest(json.as_bytes())))
174        .expect("a quoted hex digest is valid header ASCII");
175    let state = Arc::new(ServerState {
176        content: Content {
177            body: Bytes::from(json),
178            etag,
179        },
180        full_view: full_view.clone(),
181    });
182    // The guard is a `.layer` on the whole router so it also wraps the fallback — every
183    // route, the SPA fallback (and the payload endpoint) included, is born behind it. A
184    // cassette-style leak here would be worse than the document endpoint's: the whole point
185    // of the payload route is content the document endpoint deliberately withheld.
186    Router::new()
187        .route(DOCUMENT_ROUTE, get(serve_document))
188        .route(PAYLOAD_ROUTE, post(serve_payload))
189        .fallback(get(serve_ui::<A>))
190        .layer(middleware::from_fn(require_local_host))
191        .with_state(state)
192}
193
194/// The SPA handler over the embedded bundle: an exact asset is served as itself; anything
195/// else is a client-side route and lands on `index.html` (`#step-N` anchors never reach the
196/// server at all). A custom handler because `ServeDir` reads the filesystem, not an embed.
197async fn serve_ui<A: RustEmbed + 'static>(uri: Uri) -> Response {
198    let path = uri.path();
199    if path.starts_with("/api/") {
200        // A typo'd endpoint must fail loud — the fallback handing fetch() an HTML page to
201        // parse is the silent version of that bug.
202        return (
203            StatusCode::NOT_FOUND,
204            "amberfork: no such endpoint (the content endpoint is /api/document)\n",
205        )
206            .into_response();
207    }
208    let asset_path = path.trim_start_matches('/');
209    match A::get(asset_path) {
210        Some(asset) => asset_response(asset_path, asset),
211        None => match A::get(INDEX_HTML) {
212            Some(index) => asset_response(INDEX_HTML, index),
213            // Unreachable via bind (checked up front), but the library path never panics —
214            // and in a debug build the folder is re-read from disk on every request, so the
215            // bundle genuinely can vanish under a running server.
216            None => (
217                StatusCode::INTERNAL_SERVER_ERROR,
218                "amberfork: UI bundle lost index.html\n",
219            )
220                .into_response(),
221        },
222    }
223}
224
225fn asset_response(path: &str, asset: EmbeddedFile) -> Response {
226    // Cow discriminates release (borrowed from the binary) from debug (read off disk);
227    // `from_static` keeps the release path zero-copy.
228    let body = match asset.data {
229        Cow::Borrowed(bytes) => Bytes::from_static(bytes),
230        Cow::Owned(bytes) => Bytes::from(bytes),
231    };
232    let mime = mime_guess::from_path(path).first_or_octet_stream();
233    let mime = HeaderValue::from_str(mime.as_ref())
234        .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
235    ([(header::CONTENT_TYPE, mime)], body).into_response()
236}
237
238async fn serve_document(State(state): State<Arc<ServerState>>, headers: HeaderMap) -> Response {
239    let content = &state.content;
240    // `no-cache` means "revalidate, don't guess": the browser re-polls with If-None-Match
241    // instead of heuristically caching a stale document.
242    let revalidate = [
243        (header::ETAG, content.etag.clone()),
244        (header::CACHE_CONTROL, HeaderValue::from_static("no-cache")),
245    ];
246    if headers.get(header::IF_NONE_MATCH) == Some(&content.etag) {
247        return (StatusCode::NOT_MODIFIED, revalidate).into_response();
248    }
249    (
250        revalidate,
251        [(
252            header::CONTENT_TYPE,
253            HeaderValue::from_static("application/json"),
254        )],
255        content.body.clone(),
256    )
257        .into_response()
258}
259
260/// Expand-on-demand (issue #30): resolve one [`SlotAddress`] against the pre-envelope view.
261/// `404` covers every way an address can fail to resolve — out of range, the wrong kind for
262/// that row, a field path that was never in this diff — without distinguishing them; none is
263/// actionable differently by a client that only ever sends addresses it just read off a real
264/// document, and a stale address from a previous diff should look no different from a
265/// malformed one.
266async fn serve_payload(
267    State(state): State<Arc<ServerState>>,
268    Json(address): Json<SlotAddress>,
269) -> Response {
270    match state.full_view.full_text(&address) {
271        Some(text) => Json(PayloadResponse {
272            text: text.to_string(),
273        })
274        .into_response(),
275        None => (
276            StatusCode::NOT_FOUND,
277            "amberfork: no payload at that address\n",
278        )
279            .into_response(),
280    }
281}
282
283/// DNS-rebinding defense (D6, the vite/Jupyter CVE class): a hostile page can point its own
284/// domain at 127.0.0.1 and read traces cross-origin — the browser's only tell is the `Host`
285/// header, so anything that isn't a literal localhost form is refused before routing.
286async fn require_local_host(request: Request, next: Next) -> Response {
287    let allowed = request
288        .headers()
289        .get(header::HOST)
290        .and_then(|value| value.to_str().ok())
291        .is_some_and(host_is_local);
292    if !allowed {
293        return (
294            StatusCode::FORBIDDEN,
295            "amberfork: forbidden — non-local Host header\n",
296        )
297            .into_response();
298    }
299    next.run(request).await
300}
301
302/// Whether an HTTP `Host` authority names this machine's loopback: `localhost`, `127.0.0.1`,
303/// or `[::1]`, case-insensitively, with or without a port. Exact names only — suffix tricks
304/// like `localhost.evil.example` must not pass.
305fn host_is_local(host: &str) -> bool {
306    let name = if host.starts_with('[') {
307        // Bracketed IPv6 authority: the name is the bracketed span, `:port` may follow.
308        let Some(end) = host.find(']') else {
309            return false;
310        };
311        match &host[end + 1..] {
312            "" => &host[..=end],
313            port if port.starts_with(':') => &host[..=end],
314            _ => return false,
315        }
316    } else {
317        host.split(':').next().unwrap_or(host)
318    };
319    let name = name.to_ascii_lowercase();
320    name == "localhost" || name == "127.0.0.1" || name == "[::1]"
321}
322
323#[cfg(test)]
324mod tests {
325    use super::host_is_local;
326
327    #[test]
328    fn localhost_forms_pass() {
329        for host in [
330            "localhost",
331            "LOCALHOST",
332            "localhost:7777",
333            "127.0.0.1",
334            "127.0.0.1:7777",
335            "[::1]",
336            "[::1]:7777",
337        ] {
338            assert!(host_is_local(host), "{host:?} names loopback");
339        }
340    }
341
342    #[test]
343    fn everything_else_is_refused() {
344        for host in [
345            "",
346            "evil.example",
347            "evil.example:7777",
348            "localhost.evil.example",
349            "127.0.0.1.evil.example",
350            "[::2]",
351            "[::1",
352            "[::1]evil",
353            "0.0.0.0",
354            "192.168.1.10:7777",
355        ] {
356            assert!(!host_is_local(host), "{host:?} must not pass the guard");
357        }
358    }
359}