itsybitsy/bin/src/proto/http.rs

225 lines
7.3 KiB
Rust
Raw Normal View History

//! 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,
);
};
2026-10-04 20:27:29 +03:00
let cache = page.settings.max_age.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(())
}