225 lines
7.3 KiB
Rust
225 lines
7.3 KiB
Rust
|
|
//! 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(())
|
||
|
|
}
|