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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue