feat: serve rendered pages over http, spartan and nex

This commit is contained in:
randogoth 2026-10-04 19:55:21 +03:00
parent 46f12b10da
commit 2f38b8b929
30 changed files with 2896 additions and 90 deletions

224
bin/src/proto/http.rs Normal file
View file

@ -0,0 +1,224 @@
//! HTTP/1.1, hand-rolled and deliberately narrow: `GET` and `HEAD`, origin-form
//! targets, one response per connection.
//!
//! A reverse proxy in front sends origin-form, so absolute-form is refused rather
//! than half-supported. There is no keep-alive, no chunked encoding and no
//! pipelining; `Connection: close` is always sent so a client knows it.
use std::io::{BufReader, Write};
use std::net::TcpStream;
use anyhow::Result;
use itsybitsy_core::site::{Resolution, Resource};
use crate::proto::{for_log, negotiate, read_line_capped};
use crate::serve::Listener;
const MAX_REQUEST_LINE: usize = 8192;
const MAX_HEADER_LINE: usize = 8192;
const MAX_HEADERS: usize = 64;
const MAX_HEADER_BYTES: usize = 16 * 1024;
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let Some(line) = read_line_capped(&mut reader, MAX_REQUEST_LINE) else {
return bad_request(&mut stream);
};
let parts: Vec<&str> = line.split_whitespace().collect();
let [method, target, version] = parts.as_slice() else {
return bad_request(&mut stream);
};
if !version.starts_with("HTTP/1.") {
return bad_request(&mut stream);
}
// Origin-form only. smolweb's `urlsplit` would have mishandled the others.
if !target.starts_with('/') {
return bad_request(&mut stream);
}
let mut host = None;
let mut accept = None;
let mut total = 0usize;
for index in 0.. {
let Some(header) = read_line_capped(&mut reader, MAX_HEADER_LINE) else {
return bad_request(&mut stream);
};
if header.is_empty() {
break;
}
total += header.len();
if index >= MAX_HEADERS || total > MAX_HEADER_BYTES {
return bad_request(&mut stream);
}
let Some((name, value)) = header.split_once(':') else { continue };
let value = value.trim().to_string();
match name.trim().to_ascii_lowercase().as_str() {
"host" => host = Some(value),
"accept" => accept = Some(value),
_ => {}
}
}
let head_only = *method == "HEAD";
if *method != "GET" && !head_only {
return respond(
&mut stream,
405,
"Method Not Allowed",
"text/plain; charset=utf-8",
b"Method not allowed\n",
&[("Allow", "GET, HEAD")],
// Not HEAD: that case does not reach here.
false,
);
}
// A missing Host on HTTP/1.1 is a malformed request, not a missing resource:
// 404 would imply the server looked somewhere.
let Some(host) = host else { return bad_request(&mut stream) };
let Some(site) = listener.site_for(&host) else {
log::info!("{} http unknown host {}", listener.name, for_log(&host));
return respond(
&mut stream,
404,
"Not Found",
"text/plain; charset=utf-8",
b"Not found\n",
&[],
head_only,
);
};
let (path, query) = target.split_once('?').map_or((*target, ""), |(p, q)| (p, q));
log::info!("{} http {} {}", listener.name, for_log(&host), for_log(target));
// `?format=` overrides negotiation, for testing without the hardware.
let override_format = query
.split('&')
.find_map(|pair| pair.strip_prefix("format="))
.map(|value| value.to_ascii_lowercase());
let format = match &override_format {
Some(id) if listener.formats.iter().any(|f| f == id) => id.clone(),
Some(_) => {
return respond(
&mut stream,
400,
"Bad Request",
"text/plain; charset=utf-8",
b"Unknown format\n",
&[],
head_only,
);
}
None => {
negotiate::choose(&listener.registry, &listener.formats, accept.as_deref()).to_string()
}
};
match site.resolve(path) {
Ok(Resolution::Found(Resource::Document { page, .. })) => {
let Some(body) = page.body(&format) else {
return respond(
&mut stream,
500,
"Internal Server Error",
"text/plain; charset=utf-8",
b"",
&[],
head_only,
);
};
let cache = page.settings.cache_control.map(|age| format!("max-age={age}"));
let mut headers: Vec<(&str, &str)> = Vec::new();
// The response body depends on Accept, so a shared cache must not
// serve one client's format to another.
headers.push(("Vary", "Accept"));
if let Some(cache) = &cache {
headers.push(("Cache-Control", cache));
}
respond(&mut stream, 200, "OK", listener.media_type(&format), body, &headers, head_only)
}
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
let meta = std::fs::metadata(&path)?;
write_head(&mut stream, 200, "OK", media_type, meta.len(), &[])?;
if !head_only {
let mut file = std::fs::File::open(&path)?;
// Streamed, not buffered: smolweb reads the whole file into
// memory on every request.
std::io::copy(&mut file, &mut stream)?;
}
Ok(())
}
Ok(Resolution::Redirect(location)) => respond(
&mut stream,
301,
"Moved Permanently",
"text/plain; charset=utf-8",
b"",
&[("Location", location.as_str())],
head_only,
),
Ok(Resolution::NotFound) => respond(
&mut stream,
404,
"Not Found",
"text/plain; charset=utf-8",
b"Not found\n",
&[],
head_only,
),
Err(err) => {
log::warn!("{} http {}: {err}", listener.name, for_log(path));
respond(
&mut stream,
500,
"Internal Server Error",
"text/plain; charset=utf-8",
b"",
&[],
head_only,
)
}
}
}
fn bad_request(stream: &mut TcpStream) -> Result<()> {
respond(stream, 400, "Bad Request", "text/plain; charset=utf-8", b"Bad request\n", &[], false)
}
fn respond(
stream: &mut TcpStream,
code: u16,
reason: &str,
media_type: &str,
body: &[u8],
headers: &[(&str, &str)],
head_only: bool,
) -> Result<()> {
write_head(stream, code, reason, media_type, body.len() as u64, headers)?;
// HEAD sends the headers a GET would, including the length, and no body.
if !head_only {
stream.write_all(body)?;
}
Ok(())
}
fn write_head(
stream: &mut TcpStream,
code: u16,
reason: &str,
media_type: &str,
length: u64,
headers: &[(&str, &str)],
) -> Result<()> {
let mut head = format!(
"HTTP/1.1 {code} {reason}\r\nContent-Type: {media_type}\r\nContent-Length: {length}\r\n"
);
for (name, value) in headers {
head.push_str(&format!("{name}: {value}\r\n"));
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes())?;
Ok(())
}

104
bin/src/proto/mod.rs Normal file
View file

@ -0,0 +1,104 @@
//! Shared plumbing for the protocol listeners.
pub mod http;
pub mod negotiate;
pub mod nex;
pub mod spartan;
use std::io::{self, BufRead, BufReader, Read};
use std::net::TcpStream;
use std::time::Duration;
/// Apply read and write timeouts to an accepted connection, so a client that
/// stops talking cannot hold a thread indefinitely.
pub fn set_timeouts(stream: &TcpStream, secs: u64) -> io::Result<()> {
let timeout = Some(Duration::from_secs(secs));
stream.set_read_timeout(timeout)?;
stream.set_write_timeout(timeout)
}
/// Read one CRLF- or LF-terminated line, refusing anything longer than `cap`.
///
/// Returns `None` when the line exceeds the cap or the connection ends before a
/// terminator, both of which the caller answers with its bad-request status.
pub fn read_line_capped<R: Read>(reader: &mut BufReader<R>, cap: usize) -> Option<String> {
let mut line = Vec::new();
// One past the cap, so a line of exactly `cap` bytes is still accepted.
let mut limited = reader.take(cap as u64 + 1);
limited.read_until(b'\n', &mut line).ok()?;
if line.len() > cap || !line.ends_with(b"\n") {
return None;
}
while line.last().is_some_and(|b| *b == b'\n' || *b == b'\r') {
line.pop();
}
String::from_utf8(line).ok()
}
/// Render untrusted text safe to put in a log line.
///
/// A request line can carry control bytes, and a newline in a log is how one
/// request's text becomes what looks like another's entry.
pub fn for_log(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars().take(256) {
if ch.is_control() {
out.push_str(&format!("\\x{:02x}", ch as u32 & 0xff));
} else {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn read(input: &[u8], cap: usize) -> Option<String> {
read_line_capped(&mut BufReader::new(input), cap)
}
#[test]
fn reads_a_line_with_either_terminator() {
assert_eq!(read(b"GET /\r\n", 64).as_deref(), Some("GET /"));
assert_eq!(read(b"GET /\n", 64).as_deref(), Some("GET /"));
assert_eq!(read(b"\n", 64).as_deref(), Some(""));
}
#[test]
fn stops_at_the_first_line() {
assert_eq!(read(b"one\ntwo\n", 64).as_deref(), Some("one"));
}
#[test]
fn refuses_a_line_longer_than_the_cap() {
// The cap counts the terminator, so this is the longest accepted line.
assert_eq!(read(b"abcd\n", 5).as_deref(), Some("abcd"));
assert_eq!(read(b"abcde\n", 5), None);
}
#[test]
fn refuses_an_unterminated_line() {
// Otherwise a client could send a partial request and have it served.
assert_eq!(read(b"GET /", 64), None);
assert_eq!(read(b"", 64), None);
}
#[test]
fn refuses_a_line_that_is_not_utf8() {
assert_eq!(read(b"\xff\xfe\n", 64), None);
}
#[test]
fn escapes_control_bytes_for_logging() {
// A newline here is how one request forges another's log entry.
assert_eq!(for_log("GET /\r\nInjected: yes"), "GET /\\x0d\\x0aInjected: yes");
assert_eq!(for_log("/ordinary/path"), "/ordinary/path");
}
#[test]
fn truncates_a_very_long_value_for_logging() {
assert_eq!(for_log(&"a".repeat(1000)).len(), 256);
}
}

176
bin/src/proto/negotiate.rs Normal file
View file

@ -0,0 +1,176 @@
//! Choosing an output format from an HTTP `Accept` header.
//!
//! The one invariant: a wildcard never selects a format the client did not name
//! outright. A browser sends `Accept: text/html, application/xhtml+xml;q=0.9,
//! */*;q=0.8`, and that `*/*` must not be read as willingness to receive WML.
//! Only a literal media type counts as a match.
use itsybitsy_core::render::Registry;
/// Pick a format id from `accept`, out of the listener's `formats`.
///
/// `formats` is in preference order; the last entry is the fallback used when the
/// client named nothing this listener can serve, which is the ordinary case for a
/// browser.
pub fn choose<'a>(registry: &Registry, formats: &'a [String], accept: Option<&str>) -> &'a str {
let fallback = formats.last().map(String::as_str).unwrap_or("");
let Some(accept) = accept else { return fallback };
let ranges = parse(accept);
let mut best: Option<(&'a str, f32, usize)> = None;
for (index, id) in formats.iter().enumerate() {
let Some(renderer) = registry.get(id) else { continue };
let media_type = base_type(renderer.media_type());
// Literal matches only: a wildcard range is ignored entirely.
let Some(quality) = ranges
.iter()
.filter(|(range, _)| *range == media_type)
.map(|(_, q)| *q)
.fold(None, |best: Option<f32>, q| Some(best.map_or(q, |b: f32| b.max(q))))
else {
continue;
};
if quality <= 0.0 {
continue;
}
// A tie on quality is broken by the listener's own preference order.
let better = best.is_none_or(|(_, best_q, best_index)| {
quality > best_q || (quality == best_q && index < best_index)
});
if better {
best = Some((formats[index].as_str(), quality, index));
}
}
best.map(|(id, _, _)| id).unwrap_or(fallback)
}
/// `(media type, quality)` pairs, with parameters other than `q` discarded.
fn parse(accept: &str) -> Vec<(String, f32)> {
accept
.split(',')
.filter_map(|range| {
let mut parts = range.split(';');
let media_type = parts.next()?.trim().to_ascii_lowercase();
if media_type.is_empty() {
return None;
}
let quality = parts
.find_map(|param| {
let (name, value) = param.split_once('=')?;
(name.trim().eq_ignore_ascii_case("q")).then(|| value.trim())
})
.and_then(|value| value.parse::<f32>().ok())
.unwrap_or(1.0);
Some((media_type, quality))
})
.collect()
}
/// A renderer's media type without its parameters, for comparison against a range.
fn base_type(media_type: &str) -> String {
media_type.split(';').next().unwrap_or(media_type).trim().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use itsybitsy_core::Error;
use itsybitsy_core::ir::Doc;
use itsybitsy_core::render::{RenderCtx, Rendered, Renderer};
use super::*;
struct Stub(&'static str, &'static str);
impl Renderer for Stub {
fn id(&self) -> &'static str {
self.0
}
fn media_type(&self) -> &'static str {
self.1
}
fn render(&self, _doc: &Doc, _ctx: &RenderCtx<'_>) -> Result<Rendered, Error> {
Ok(Rendered::body(Vec::new()))
}
}
fn registry() -> Registry {
let mut registry = Registry::new();
registry.insert(Arc::new(Stub("wml", "text/vnd.wap.wml; charset=utf-8"))).unwrap();
registry
.insert(Arc::new(Stub("xhtmlmp", "application/vnd.wap.xhtml+xml; charset=utf-8")))
.unwrap();
registry.insert(Arc::new(Stub("html", "text/html; charset=utf-8"))).unwrap();
registry
}
fn formats() -> Vec<String> {
["wml", "xhtmlmp", "html"].iter().map(|s| s.to_string()).collect()
}
#[track_caller]
fn choose_with(accept: Option<&str>) -> String {
choose(&registry(), &formats(), accept).to_string()
}
#[test]
fn a_wildcard_never_selects_a_format_the_client_did_not_name() {
// The invariant the whole module exists for.
assert_eq!(choose_with(Some("*/*")), "html");
assert_eq!(choose_with(Some("text/*")), "html");
assert_eq!(choose_with(Some("text/html,application/xhtml+xml;q=0.9,*/*;q=0.8")), "html");
}
#[test]
fn no_header_at_all_falls_back() {
assert_eq!(choose_with(None), "html");
assert_eq!(choose_with(Some("")), "html");
}
#[test]
fn a_literal_media_type_is_honoured() {
assert_eq!(choose_with(Some("text/vnd.wap.wml")), "wml");
assert_eq!(choose_with(Some("application/vnd.wap.xhtml+xml")), "xhtmlmp");
assert_eq!(choose_with(Some("text/html")), "html");
}
#[test]
fn a_media_type_with_parameters_still_matches() {
assert_eq!(choose_with(Some("text/vnd.wap.wml; charset=utf-8")), "wml");
assert_eq!(choose_with(Some("TEXT/VND.WAP.WML")), "wml");
}
#[test]
fn quality_decides_between_two_named_formats() {
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.5,text/html;q=0.9")), "html");
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0.9,text/html;q=0.5")), "wml");
}
#[test]
fn a_tie_breaks_by_the_listeners_preference_order() {
assert_eq!(choose_with(Some("text/html,text/vnd.wap.wml")), "wml");
let reversed: Vec<String> =
["html", "xhtmlmp", "wml"].iter().map(|s| s.to_string()).collect();
assert_eq!(choose(&registry(), &reversed, Some("text/html,text/vnd.wap.wml")), "html");
}
#[test]
fn a_zero_quality_refuses_that_format() {
assert_eq!(choose_with(Some("text/vnd.wap.wml;q=0")), "html");
}
#[test]
fn a_format_this_listener_does_not_serve_is_not_chosen() {
let only_html = vec!["html".to_string()];
assert_eq!(choose(&registry(), &only_html, Some("text/vnd.wap.wml")), "html");
}
#[test]
fn a_malformed_header_falls_back_rather_than_failing() {
assert_eq!(choose_with(Some(";;;")), "html");
assert_eq!(choose_with(Some("text/html;q=abc")), "html");
}
}

56
bin/src/proto/nex.rs Normal file
View file

@ -0,0 +1,56 @@
//! Nex: a bare path in, bytes out, no status line and no headers.
//!
//! There is nothing to signal an error with, so a failure is a plain-text body,
//! and nothing to redirect with either, which is why resolution follows the
//! canonical target server-side rather than bouncing the client.
use std::io::{BufReader, Write};
use std::net::TcpStream;
use anyhow::Result;
use itsybitsy_core::site::{Resolution, Resource};
use crate::proto::{for_log, read_line_capped};
use crate::serve::Listener;
/// Nex requests are a single path; the cap is smolweb's.
const MAX_REQUEST: usize = 2048;
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
let selector = {
let mut reader = BufReader::new(stream.try_clone()?);
read_line_capped(&mut reader, MAX_REQUEST)
};
let Some(selector) = selector else {
log::warn!("{}: unreadable or oversized request", listener.name);
stream.write_all(b"Bad request\n")?;
return Ok(());
};
// Nex carries no hostname, so the listener names its site outright and
// configuration validation has already proved it exists.
let site = listener.only_site();
let path = if selector.is_empty() { "/" } else { &selector };
log::info!("{} nex {}", listener.name, for_log(path));
match site.resolve_flat(path) {
Ok(Resolution::Found(Resource::Document { url, page })) => {
match page.body(&listener.formats[0]) {
Some(body) => stream.write_all(body)?,
None => stream.write_all(b"Internal error\n")?,
}
let _ = url;
}
Ok(Resolution::Found(Resource::Raw { path, .. })) => {
let mut file = std::fs::File::open(&path)?;
std::io::copy(&mut file, &mut stream)?;
}
// resolve_flat never yields a redirect; it is matched for completeness.
Ok(_) => stream.write_all(b"Not found\n")?,
Err(err) => {
log::warn!("{} nex {}: {err}", listener.name, for_log(path));
stream.write_all(b"Internal error\n")?;
}
}
Ok(())
}

86
bin/src/proto/spartan.rs Normal file
View file

@ -0,0 +1,86 @@
//! Spartan: `HOST PATH LENGTH` in, a one-digit status and a body out.
//!
//! The host field is what smolweb discards; here it selects the virtual host,
//! falling back to the listener's `default_site` when it names nothing known.
use std::io::{BufReader, Read, Write};
use std::net::TcpStream;
use anyhow::Result;
use itsybitsy_core::site::{Resolution, Resource};
use crate::proto::{for_log, read_line_capped};
use crate::serve::Listener;
const MAX_REQUEST: usize = 4096;
/// An upload is refused either way, but a small body is drained first so the
/// connection stays in sync; a large one is not worth reading to discard.
const MAX_DRAIN: u64 = 1024 * 1024;
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let Some(line) = read_line_capped(&mut reader, MAX_REQUEST) else {
log::warn!("{}: unreadable or oversized request", listener.name);
return status(&mut stream, 4, "Bad request", b"");
};
let parts: Vec<&str> = line.split(' ').collect();
let [host, path, length] = parts.as_slice() else {
log::warn!("{} spartan: malformed request {}", listener.name, for_log(&line));
return status(&mut stream, 4, "Bad request", b"");
};
let Ok(length) = length.parse::<u64>() else {
return status(&mut stream, 4, "Bad request", b"");
};
if length > 0 {
// Drain before refusing, or the client's body would be read as the next
// request on a reused connection.
if length <= MAX_DRAIN {
let mut sink = io_sink();
std::io::copy(&mut reader.take(length), &mut sink)?;
}
return status(&mut stream, 4, "Uploads are not accepted", b"");
}
let Some(site) = listener.site_for(host) else {
log::info!("{} spartan unknown host {}", listener.name, for_log(host));
return status(&mut stream, 4, "Unknown host", b"");
};
log::info!("{} spartan {} {}", listener.name, for_log(host), for_log(path));
let format = &listener.formats[0];
match site.resolve(path) {
Ok(Resolution::Found(Resource::Document { page, .. })) => match page.body(format) {
Some(body) => {
let media_type = listener.media_type(format);
status(&mut stream, 2, media_type, body)
}
None => status(&mut stream, 5, "Not rendered", b""),
},
Ok(Resolution::Found(Resource::Raw { path, media_type })) => {
write!(stream, "2 {media_type}\r\n")?;
let mut file = std::fs::File::open(&path)?;
std::io::copy(&mut file, &mut stream)?;
Ok(())
}
Ok(Resolution::Redirect(location)) => status(&mut stream, 3, &location, b""),
Ok(Resolution::NotFound) => status(&mut stream, 4, "Not found", b""),
Err(err) => {
log::warn!("{} spartan {}: {err}", listener.name, for_log(path));
status(&mut stream, 5, "Internal error", b"")
}
}
}
fn status(stream: &mut TcpStream, code: u8, meta: &str, body: &[u8]) -> Result<()> {
write!(stream, "{code} {meta}\r\n")?;
if !body.is_empty() {
stream.write_all(body)?;
}
Ok(())
}
fn io_sink() -> std::io::Sink {
std::io::sink()
}