feat: serve rendered pages over http, spartan and nex
This commit is contained in:
parent
46f12b10da
commit
2f38b8b929
30 changed files with 2896 additions and 90 deletions
224
bin/src/proto/http.rs
Normal file
224
bin/src/proto/http.rs
Normal 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
104
bin/src/proto/mod.rs
Normal 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
176
bin/src/proto/negotiate.rs
Normal 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(®istry(), &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(®istry(), &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(®istry(), &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
56
bin/src/proto/nex.rs
Normal 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
86
bin/src/proto/spartan.rs
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue