//! 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; the other request-target forms are refused, not // half-supported. 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.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::Part { parent_url, slug, page })) => { match page.part(&format, &slug) { Some(body) => respond( &mut stream, 200, "OK", listener.media_type(&format), body, &[("Vary", "Accept")], head_only, ), // This format does not address that sub-document, so the parent // is where the content actually is. None => respond( &mut stream, 301, "Moved Permanently", "text/plain; charset=utf-8", b"", &[("Location", parent_url.as_str()), ("Vary", "Accept")], 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, so a large file costs the copy buffer // rather than its own size 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(()) }