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}