#[cfg(feature = "auth")]
pub mod auth;
mod form;
#[cfg(feature = "markdown")]
pub mod markdown;
mod request;
mod session;
pub mod testing;
mod throttle;
mod upload;
mod view;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Component, Path};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::SyncSender;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use crate::log::Log;
#[cfg(feature = "regex")]
pub use form::Patterns;
pub use form::{Form, Invalid};
pub use request::{MAX_BODY, Request};
pub use session::{COOKIE, CSRF_FIELD, Session, Sessions, csrf};
pub use throttle::{Throttle, throttle};
pub use upload::{MAX_UPLOAD, UploadedFile};
pub use view::{ToValue, Value};
pub const PUBLIC: &str = "public";
const READ_TIMEOUT: Duration = Duration::from_secs(10);
pub type Next<'a> = &'a dyn Fn(&Request) -> Response;
type Handler = Box<dyn Fn(&Request) -> Response + Send + Sync>;
struct Route {
method: &'static str,
pattern: String,
handler: Handler,
body_limit: Option<usize>,
}
pub trait IntoResponse {
fn into_response(self, request: &Request) -> Response;
}
impl IntoResponse for Response {
fn into_response(self, _: &Request) -> Response {
self
}
}
impl<T: IntoResponse, E: std::fmt::Display> IntoResponse for std::result::Result<T, E> {
fn into_response(self, request: &Request) -> Response {
match self {
Ok(response) => response.into_response(request),
Err(problem) => {
Log::error(format_args!(
"{} {}: {problem}",
request.method, request.path
));
error(500)
}
}
}
}
pub type Result<T = Response> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
type Middleware = Arc<dyn Fn(&Request, Next) -> Response + Send + Sync>;
#[derive(Default)]
pub struct Router {
routes: Vec<Route>,
middleware: Vec<Middleware>,
state: request::State,
reveal_forbidden: bool,
json_errors: bool,
body_limit: Option<usize>,
}
impl Router {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get<R: IntoResponse>(
self,
path: &str,
handler: impl Fn(&Request) -> R + Send + Sync + 'static,
) -> Self {
self.route("GET", path, handler)
}
#[must_use]
pub fn post<R: IntoResponse>(
self,
path: &str,
handler: impl Fn(&Request) -> R + Send + Sync + 'static,
) -> Self {
self.route("POST", path, handler)
}
#[must_use]
pub fn up(self, path: &str) -> Self {
self.get(path, |request: &Request| {
#[cfg(feature = "db")]
if let Some(db) = request.state::<crate::db::Db>()
&& db
.with(|sql| sql.query_row("SELECT 1", &[], |_| Ok(())))
.is_err()
{
return error(503);
}
#[cfg(not(feature = "db"))]
let _ = request;
json(r#"{"status":"ok"}"#)
})
}
#[cfg(feature = "metrics")]
#[must_use]
pub fn metrics(self, path: &str, metrics: Arc<crate::metrics::Registry>) -> Self {
let scrape = Arc::clone(&metrics);
let own = path.to_owned();
self.middleware(move |request, next| {
if request.path == own {
return next(request);
}
let started = Instant::now();
let response = next(request);
let micros = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
metrics.inc("http_requests_total");
metrics.add("http_request_duration_microseconds_total", micros);
if response.status >= 500 {
metrics.inc("http_server_errors_total");
}
response
})
.get(path, move |_| {
Response::new(
200,
"text/plain; version=0.0.4; charset=utf-8",
scrape.render(),
)
})
}
#[must_use]
pub fn view(self, path: &str, name: &'static str) -> Self {
self.get(path, move |_| view(name))
}
#[must_use]
pub fn route<R: IntoResponse>(
mut self,
method: &'static str,
path: &str,
handler: impl Fn(&Request) -> R + Send + Sync + 'static,
) -> Self {
for segment in path.split('/') {
if let Some((_, kind)) = segment
.strip_prefix('{')
.and_then(|name| name.strip_suffix('}'))
.and_then(|name| name.split_once(':'))
{
assert!(
matches!(kind, "u32" | "u64" | "i32" | "i64"),
"unknown route parameter type `{kind}` in {path}"
);
}
}
self.routes.push(Route {
method,
pattern: path.into(),
handler: Box::new(move |request: &Request| handler(request).into_response(request)),
body_limit: None,
});
self
}
#[must_use]
pub fn state<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
Arc::make_mut(&mut self.state.0).push(Arc::new(value));
self
}
#[must_use]
pub fn middleware(
mut self,
middleware: impl Fn(&Request, Next) -> Response + Send + Sync + 'static,
) -> Self {
self.middleware.push(Arc::new(middleware));
self
}
#[must_use]
pub fn group(mut self, build: impl FnOnce(Router) -> Router) -> Self {
let group = build(Router::new());
let chain: Arc<[Middleware]> = group.middleware.into();
let json = group.json_errors;
for route in group.routes {
let chain = Arc::clone(&chain);
let handler = route.handler;
self.routes.push(Route {
handler: Box::new(move |request: &Request| {
let response = run(&chain, request, &handler);
if json {
problem_page(response)
} else {
response
}
}),
body_limit: route.body_limit.or(group.body_limit),
..route
});
}
self
}
#[must_use]
pub fn json_errors(mut self) -> Self {
self.json_errors = true;
self
}
#[must_use]
pub fn body_limit(mut self, bytes: usize) -> Self {
self.body_limit = Some(bytes);
self
}
fn limit_for(&self, request: &Request) -> usize {
let route = self.routes.iter().find(|route| {
route.method == request.method && params(&route.pattern, &request.path).is_some()
});
route
.and_then(|route| route.body_limit)
.or(self.body_limit)
.unwrap_or_else(|| request::default_limit(request))
}
fn finish(&self, response: Response) -> Response {
if self.json_errors {
problem_page(response)
} else {
response
}
}
#[must_use]
pub fn reveal_forbidden(mut self) -> Self {
self.reveal_forbidden = true;
self
}
#[must_use]
pub fn package(self, package: impl Package) -> Self {
self.group(|router| package.routes(router))
}
pub fn handle(&self, request: &Request) -> Response {
let reference = request.reference();
crate::log::with_reference(reference, || self.handle_in_scope(request))
.with_header("x-request-id", &reference.to_string())
}
fn handle_in_scope(&self, request: &Request) -> Response {
let response = if self.state.0.is_empty() {
run(&self.middleware, request, &|request| self.dispatch(request))
} else {
let mut request = request.clone();
request.state = self.state.clone();
run(&self.middleware, &request, &|request| {
self.dispatch(request)
})
};
let response = self.finish(response);
if response.status == 403 && !self.reveal_forbidden {
Log::notice(format_args!(
"http_status_code=403 {} {}",
request.method, request.path
));
let mut hidden = self.finish(error(404));
hidden.headers = response
.headers
.into_iter()
.filter(|(name, _)| !name.eq_ignore_ascii_case("set-cookie"))
.collect();
return hidden;
}
response
}
fn dispatch(&self, request: &Request) -> Response {
let matched = self.routes.iter().find_map(|route| {
let params = (route.method == request.method)
.then(|| params(&route.pattern, &request.path))??;
Some((&route.handler, params))
});
match matched {
Some(_) if request.body_too_large() => error(413),
Some((handler, params)) if params.is_empty() => handler(request),
Some((handler, params)) => {
let mut request = request.clone();
request.params = params;
handler(&request)
}
None => self.unmatched(request),
}
}
fn unmatched(&self, request: &Request) -> Response {
let mut allow: Vec<&str> = self
.routes
.iter()
.filter(|route| params(&route.pattern, &request.path).is_some())
.map(|route| route.method)
.collect();
allow.sort_unstable();
allow.dedup();
if request.method == "GET" && !self.json_errors {
let file = asset(&request.path);
if file.status != 404 || allow.is_empty() {
return file;
}
}
if allow.is_empty() {
error(404)
} else {
error(405).with_header("Allow", &allow.join(", "))
}
}
}
pub trait Package {
fn routes(self, router: Router) -> Router;
}
pub fn package_view(
package: &str,
name: &str,
embedded: &str,
data: &[(&str, &dyn ToValue)],
) -> Response {
package_view_in(Path::new(PUBLIC), package, name, embedded, data)
}
fn package_view_in(
public: &Path,
package: &str,
name: &str,
embedded: &str,
data: &[(&str, &dyn ToValue)],
) -> Response {
let full = format!("vendor/{package}/{name}");
let source = built_view(public, &full).unwrap_or_else(|| embedded.to_owned());
rendered(public, &full, &source, Value::map(data))
}
fn params(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
let mut params = Vec::new();
let mut segments = path.split('/');
for expected in pattern.split('/') {
let segment = segments.next()?;
match expected
.strip_prefix('{')
.and_then(|name| name.strip_suffix('}'))
{
Some(name) if !segment.is_empty() => {
let (name, kind) = name.split_once(':').unwrap_or((name, ""));
let fits = match kind {
"u32" => segment.parse::<u32>().is_ok(),
"u64" => segment.parse::<u64>().is_ok(),
"i32" => segment.parse::<i32>().is_ok(),
"i64" => segment.parse::<i64>().is_ok(),
_ => true,
};
if !fits {
return None;
}
params.push((name.to_owned(), segment.to_owned()));
}
Some(_) => return None,
None if expected == segment => {}
None => return None,
}
}
segments.next().is_none().then_some(params)
}
fn run(chain: &[Middleware], request: &Request, last: Next) -> Response {
match chain.split_first() {
None => last(request),
Some((first, rest)) => first(request, &|request| run(rest, request, last)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub status: u16,
pub content_type: &'static str,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl Response {
pub fn new(status: u16, content_type: &'static str, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
content_type,
headers: Vec::new(),
body: body.into(),
}
}
pub fn text(status: u16, body: &str) -> Self {
Self::new(status, "text/plain; charset=utf-8", body)
}
#[must_use]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
let clean = |text: &str| text.replace(['\r', '\n'], " ");
self.headers.push((clean(name), clean(value)));
self
}
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
pub fn json(body: &str) -> Response {
json_status(200, body)
}
pub fn json_status(status: u16, body: &str) -> Response {
Response::new(status, "application/json", body)
}
pub fn problem(status: u16, detail: &str) -> Response {
let body = crate::error::Problem::new(status, reason(status), detail).json();
Response::new(status, "application/problem+json", body)
}
fn problem_page(response: Response) -> Response {
if response.status < 400 || !response.content_type.starts_with("text/html") {
return response;
}
let mut json = problem(response.status, reason(response.status));
json.headers = response.headers;
json
}
pub fn redirect(location: &str) -> Response {
Response::text(302, "").with_header("Location", location)
}
pub fn view(name: &str) -> Response {
render(name, &[])
}
pub fn render(name: &str, data: &[(&str, &dyn ToValue)]) -> Response {
render_value(name, Value::map(data))
}
fn render_value(name: &str, data: Value) -> Response {
let public = Path::new(PUBLIC);
match built_view(public, name) {
Some(source) => rendered(public, name, &source, data),
None => page(
503,
"The frontend is not built yet. If <code>clamp dev</code> is running, refresh in a moment; otherwise run <code>clamp dev</code>.",
),
}
}
fn built_view(public: &Path, name: &str) -> Option<String> {
if !view::is_view_name(name) {
return None;
}
let file = public
.join("build/views")
.join(format!("{}.html", name.replace('.', "/")));
fs::read_to_string(file).ok()
}
fn rendered(public: &Path, name: &str, source: &str, data: Value) -> Response {
match view::render(name, source, data, &|name| built_view(public, name)) {
Ok(body) => html(200, body.into_bytes()),
Err(problem) => {
Log::error(format_args!("{problem}"));
error(500)
}
}
}
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
escape_into(text, &mut out);
out
}
fn escape_into(text: &str, out: &mut String) {
let mut rest = text;
while let Some(at) = rest.find(['&', '<', '>', '"', '\'']) {
out.push_str(&rest[..at]);
out.push_str(match rest.as_bytes()[at] {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
_ => "'",
});
rest = &rest[at + 1..];
}
out.push_str(rest);
}
pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; \
object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
pub fn security_headers(request: &Request, next: Next) -> Response {
let response = next(request);
let has_policy = response.header("content-security-policy").is_some();
let response = response
.with_header("X-Content-Type-Options", "nosniff")
.with_header("X-Frame-Options", "DENY")
.with_header("Referrer-Policy", "strict-origin-when-cross-origin")
.with_header("Strict-Transport-Security", "max-age=31536000")
.with_header(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
);
if has_policy {
response
} else {
response.with_header("Content-Security-Policy", CONTENT_SECURITY_POLICY)
}
}
pub fn error(status: u16) -> Response {
let public = Path::new(PUBLIC);
let reference = crate::log::reference().map(|reference| reference.to_string());
let data = Value::map(&[
("status", &status),
("reason", &reason(status)),
("reference", &reference.clone().unwrap_or_default()),
]);
for name in [
format!("errors/{status}"),
format!("errors/{}xx", status / 100),
] {
if let Some(source) = built_view(public, &name) {
match view::render(&name, &source, data.clone(), &|name| {
built_view(public, name)
}) {
Ok(body) => return html(status, body.into_bytes()),
Err(problem) => {
Log::error(format_args!("{problem}"));
break;
}
}
}
}
match reference {
Some(reference) if status >= 500 => {
page(status, &format!("Reference: <code>{reference}</code>"))
}
_ => page(status, ""),
}
}
fn html(status: u16, body: Vec<u8>) -> Response {
Response::new(status, "text/html; charset=utf-8", body)
}
fn page(status: u16, detail: &str) -> Response {
let reason = reason(status);
let detail = if detail.is_empty() {
String::new()
} else {
format!("<p>{detail}</p>")
};
html(
status,
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{status} {reason}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500&family=Geist+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root {{ color-scheme: light; --bg: #fff; --fg: #0a0a0a; --fg-2: #525252; --line: #00000018; --accent: #ea580c; --accent-strong: #c2410c;
--grad: linear-gradient(256.93deg, #b45309 -35%, #d97706 -17%, #ea580c 12%, #c2410c 27%, #db2777 68%, #dc2626 129%, #f59e0b 158%); }}
@media (prefers-color-scheme: dark) {{ :root {{ color-scheme: dark; --bg: #0a0a0a; --fg: #eeeeec; --fg-2: #b5b3ad; --line: #ffffff20; --accent: #fb923c; --accent-strong: #fdba74;
--grad: linear-gradient(256.93deg, #fde68a -35%, #fcd34d -17%, #fb923c 12%, #fdba74 27%, #f9a8d4 68%, #fca5a5 129%, #fed7aa 158%); }} }}
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font: 400 16px/1.6 "Instrument Sans", ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased;
background-image: radial-gradient(50% 45% at 50% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%); }}
main {{ padding: 1.5rem; max-width: 40rem; text-align: center; }}
.big {{ margin: 1rem 0 0; font-size: clamp(6rem, 22vw, 12rem); line-height: .9; letter-spacing: -0.06em; background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; padding: .12em 0; }}
h1 {{ margin: .5rem 0 0; font-weight: 400; letter-spacing: -0.025em; line-height: 1.1; font-size: clamp(2rem, 5vw, 3rem); text-wrap: balance; }}
p {{ color: var(--fg-2); font-size: 1.1rem; text-wrap: pretty; }}
code {{ font: .9em "Geist Mono", ui-monospace, monospace; color: var(--fg); }}
a {{ display: inline-flex; align-items: center; height: 3rem; margin-top: 1.5rem; padding: 0 1.5rem; border-radius: 999px; background: var(--accent); color: #fff; font-size: 1.05rem; text-decoration: none; box-shadow: 0 1px 2px #0000000d; transition: background-color 60ms ease-out; }}
a:hover {{ background: var(--accent-strong); }}
a:focus-visible {{ outline: 2px solid var(--accent); outline-offset: 2px; }}
</style>
</head>
<body><main><p class="big">{status}</p><h1>{reason}</h1>{detail}<a href="/">Go home</a></main></body>
</html>
"#
)
.into_bytes(),
)
}
pub fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
304 => "Not Modified",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
411 => "Length Required",
413 => "Content Too Large",
419 => "Page Expired",
422 => "Unprocessable Content",
429 => "Too Many Requests",
431 => "Request Header Fields Too Large",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => match status / 100 {
2 => "Success",
3 => "Redirect",
4 => "Client Error",
_ => "Server Error",
},
}
}
pub fn asset(path: &str) -> Response {
let relative = Path::new(path.trim_start_matches('/'));
if !relative
.components()
.all(|part| matches!(part, Component::Normal(_)))
{
return error(404);
}
if relative.starts_with("build/views") {
return error(404);
}
let file = Path::new(PUBLIC).join(relative);
let Ok(body) = fs::read(&file) else {
return error(404);
};
Response::new(200, content_type(&file), body)
}
fn content_type(file: &Path) -> &'static str {
let extension = file
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript",
Some("css") => "text/css",
Some("json") => "application/json",
Some("txt") => "text/plain; charset=utf-8",
Some("pdf") => "application/pdf",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}
#[cfg(feature = "db")]
#[derive(Debug, Clone, Copy)]
pub struct Command {
pub name: &'static str,
pub description: &'static str,
pub run: fn(&[String], &crate::config::Config, &crate::db::Db) -> i32,
}
#[cfg(feature = "db")]
const BUILT_IN: &[(&str, &str)] = &[
("list", "List the commands (also: help)"),
("help", ""),
("migrate", "Run the pending migrations"),
("migrate:status", "Show which migrations ran"),
("migrate:rollback", "Roll back the last batch of migrations"),
("db:seed", "Migrate, then run the seeders"),
(
"user:create",
"Create a verified user: user:create EMAIL ROLE [NAME]",
),
(
"queue:work",
"Run only the queue workers (QUEUE_WORKERS, at least one)",
),
("queue:retry", "Queue failed jobs again: queue:retry ID|all"),
("queue:forget", "Delete failed jobs: queue:forget ID|all"),
(
"schedule:run",
"Run the scheduled tasks due this minute (cron runs it every minute)",
),
];
#[cfg(feature = "db")]
#[derive(Debug, Clone, Copy)]
pub struct App {
pub logging: fn(&crate::config::Config) -> crate::log::Settings,
pub database: fn(&crate::config::Config) -> crate::db::Settings,
pub filesystems: fn(&crate::config::Config) -> crate::storage::Settings,
pub migrations: fn() -> Vec<&'static dyn crate::db::Migration>,
pub seeders: fn() -> Vec<&'static dyn crate::db::Seeder>,
pub routes: fn(Router, &crate::config::Config, &crate::db::Db) -> Router,
pub commands: &'static [Command],
#[cfg(feature = "auth")]
pub roles: &'static [&'static str],
#[cfg(feature = "mail")]
pub mail: fn(&crate::config::Config) -> crate::mail::Settings,
#[cfg(feature = "queue")]
pub jobs: fn() -> Vec<(&'static str, crate::queue::Handler)>,
#[cfg(feature = "schedule")]
pub schedule: &'static [crate::schedule::Task],
}
#[cfg(feature = "db")]
impl App {
pub fn run(self) {
use crate::db::Db;
use crate::log::Logger;
use crate::storage::Storage;
let config = crate::config::Config::load();
Log::init(Logger::new(&(self.logging)(&config)));
self.check_commands();
let db = Db::connect(&(self.database)(&config));
#[cfg(feature = "schedule")]
self.schedule(&config, db.clone());
let args: Vec<String> = std::env::args().skip(1).collect();
if let [name, args @ ..] = args.as_slice() {
std::process::exit(self.command(&config, &db, name, args));
}
db.migrate(&(self.migrations)())
.unwrap_or_else(|error| panic!("migration failed: {error}"));
if let Err(error) = Storage::new((self.filesystems)(&config)).link() {
Log::warning(format_args!("storage link failed: {error}"));
}
#[cfg(feature = "mail")]
std::sync::Arc::new(crate::mail::Mailer::new((self.mail)(&config))).start(db.clone());
#[cfg(feature = "queue")]
{
let (queue, workers) = (
self.queue(&config, db.clone()),
crate::queue::workers(&config),
);
if workers == 0
&& let Ok(waiting @ 1..) = queue.waiting()
{
Log::warning(format_args!(
"{waiting} queued jobs and QUEUE_WORKERS=0: run the app with queue:work, or nothing runs them"
));
}
queue.start(workers, &Shutdown::new());
}
#[cfg(feature = "schedule")]
if !self.schedule.is_empty() {
let schedule = self.schedule(&config, db.clone());
if crate::schedule::thread(&config) {
schedule.start();
} else if let Ok(last) = schedule.last_run() {
let hour_ago = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64 - 3600);
if last.is_none_or(|last| last < hour_ago) {
Log::warning(format_args!(
"scheduled tasks and SCHEDULE_THREAD=false, but no schedule:run in the last hour: add a cron line, or nothing runs them"
));
}
}
}
serve(self.router(&config, db));
}
pub fn router(&self, config: &crate::config::Config, db: crate::db::Db) -> Router {
let router = Router::new()
.state(db.clone())
.state(crate::storage::Storage::new((self.filesystems)(config)))
.middleware(security_headers);
#[cfg(feature = "auth")]
let router = router.state(auth::Auth::from_config(self.roles, config));
#[cfg(feature = "mail")]
let router = router.state(std::sync::Arc::new(crate::mail::Mailer::new((self.mail)(
config,
))));
#[cfg(feature = "queue")]
let router = router.state(self.queue(config, db.clone()));
(self.routes)(router, config, &db)
}
#[cfg(feature = "queue")]
pub fn queue(&self, config: &crate::config::Config, db: crate::db::Db) -> crate::queue::Queue {
crate::queue::Queue::new(config, db, (self.jobs)())
.unwrap_or_else(|error| panic!("{error}"))
}
#[cfg(feature = "schedule")]
pub fn schedule(
&self,
config: &crate::config::Config,
db: crate::db::Db,
) -> crate::schedule::Schedule {
crate::schedule::Schedule::new(self.schedule, config, db)
}
pub fn command(
&self,
config: &crate::config::Config,
db: &crate::db::Db,
name: &str,
args: &[String],
) -> i32 {
match name {
"list" | "help" => {
print!("{}", self.list());
0
}
"migrate" | "migrate:status" | "migrate:rollback" | "db:seed" => {
crate::db::command(db, name, &(self.migrations)(), &(self.seeders)())
}
#[cfg(feature = "auth")]
"user:create" => self.create_user(db, args),
#[cfg(feature = "queue")]
"queue:work" => {
let workers = crate::queue::workers(config).max(1);
println!("Working the queue on {workers} threads");
for worker in self
.queue(config, db.clone())
.start(workers, &Shutdown::new())
{
let _ = worker.join();
}
0
}
#[cfg(feature = "queue")]
"queue:retry" | "queue:forget" => self.failed_jobs(config, db, name, args),
#[cfg(feature = "schedule")]
"schedule:run" => match self
.schedule(config, db.clone())
.run_due(std::time::SystemTime::now())
{
Ok(ran) => i32::from(ran.iter().any(|(_, result)| result.is_err())),
Err(error) => {
eprintln!("schedule:run failed: {error}");
1
}
},
_ => match self.commands.iter().find(|command| command.name == name) {
Some(command) => (command.run)(args, config, db),
None => {
eprintln!("unknown command {name:?}; `cargo run -- list` shows them");
2
}
},
}
}
#[cfg(feature = "queue")]
fn failed_jobs(
&self,
config: &crate::config::Config,
db: &crate::db::Db,
name: &str,
args: &[String],
) -> i32 {
let id = match args {
[all] if all == "all" => None,
[id] if id.parse::<i64>().is_ok() => id.parse().ok(),
_ => {
eprintln!("usage: {name} ID|all");
return 2;
}
};
let queue = self.queue(config, db.clone());
let (done, result) = match name {
"queue:retry" => ("Queued again", queue.retry(id)),
_ => ("Deleted", queue.forget(id)),
};
match result {
Ok(jobs) if jobs.is_empty() => match id {
Some(id) => {
eprintln!("no failed job {id}");
1
}
None => {
println!("No failed jobs");
0
}
},
Ok(jobs) => {
for (id, job) in jobs {
println!("{done} failed job {id} ({job})");
}
0
}
Err(error) => {
eprintln!("{name} failed: {error}");
1
}
}
}
pub fn list(&self) -> String {
let commands: Vec<(&str, &str)> = BUILT_IN
.iter()
.copied()
.filter(|(name, _)| *name != "help")
.filter(|(name, _)| cfg!(feature = "auth") || *name != "user:create")
.filter(|(name, _)| cfg!(feature = "queue") || !name.starts_with("queue:"))
.filter(|(name, _)| cfg!(feature = "schedule") || !name.starts_with("schedule:"))
.chain(
self.commands
.iter()
.map(|command| (command.name, command.description)),
)
.collect();
let width = commands
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0);
let mut list = String::from(
"Usage: cargo run -- [COMMAND] [ARGS]; no command serves the app.\n\nCommands:\n",
);
for (name, description) in commands {
list += &format!(" {name:width$} {description}\n");
}
list
}
fn check_commands(&self) {
for (at, command) in self.commands.iter().enumerate() {
let taken = BUILT_IN.iter().any(|(name, _)| *name == command.name)
|| self.commands[..at]
.iter()
.any(|other| other.name == command.name);
assert!(!taken, "app command {:?} is already taken", command.name);
}
}
#[cfg(feature = "auth")]
fn create_user(&self, db: &crate::db::Db, args: &[String]) -> i32 {
let [email, role, rest @ ..] = args else {
eprintln!("usage: user:create <email> <role> [name]");
return 2;
};
if let Err(error) = db.migrate(&(self.migrations)()) {
eprintln!("migration failed: {error}");
return 1;
}
let name = rest.first().map_or(email.as_str(), String::as_str);
let password = session::random_token()[..24].to_owned();
let auth = auth::Auth::new(self.roles);
match auth
.create_user(db, name, email, &password, role)
.and_then(|id| auth.mark_verified(db, id))
{
Ok(()) => {
println!("Created {email} ({role}). Password, shown once: {password}");
0
}
Err(error) => {
eprintln!("could not create {email}: {error}");
1
}
}
}
pub fn test(&self, env: &str) -> (Router, crate::db::Db) {
let config = crate::config::Config::parse(&format!(
"DB_DATABASE=:memory:\nAPP_URL=http://localhost\n\
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n{env}"
));
let db = crate::db::Db::connect(&(self.database)(&config));
db.migrate(&(self.migrations)())
.unwrap_or_else(|error| panic!("migration failed: {error}"));
db.seed(&(self.seeders)())
.unwrap_or_else(|error| panic!("seeding failed: {error}"));
(self.router(&config, db.clone()), db)
}
}
#[cfg(any(feature = "queue", feature = "schedule"))]
pub(crate) fn catch_panic(
work: impl FnOnce() -> std::result::Result<(), String>,
) -> std::result::Result<(), String> {
catch_unwind(AssertUnwindSafe(work))
.unwrap_or_else(|panic| Err(format!("panicked: {}", panic_reason(&*panic))))
}
fn panic_reason(panic: &(dyn std::any::Any + Send)) -> String {
panic
.downcast_ref::<&str>()
.map(|text| (*text).to_owned())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_default()
}
const THREADS: usize = 32;
pub fn serve(routes: Router) {
match try_serve(routes) {
Ok(()) => {}
Err(ServeError::Config(error)) => panic!("{error}"),
Err(error @ ServeError::Bind { .. }) => {
eprintln!("{error}");
std::process::exit(1);
}
}
}
pub fn try_serve(routes: Router) -> std::result::Result<(), ServeError> {
let (listener, threads) = bind(&crate::config::Config::from_env())?;
let address = listener.local_addr().expect("listener has an address");
println!("Serving on http://{address}");
serve_on(listener, routes, threads);
Ok(())
}
fn bind(config: &crate::config::Config) -> std::result::Result<(TcpListener, usize), ServeError> {
let ports: Vec<u16> = match config.get_parsed("PORT").map_err(ServeError::Config)? {
Some(port) => vec![port],
None => (8080..8100).chain([0]).collect(),
};
let threads = config
.get_parsed("WEB_THREADS")
.map_err(ServeError::Config)?
.unwrap_or(THREADS);
let mut last = None;
let listener = ports
.iter()
.find_map(|port| {
TcpListener::bind(("127.0.0.1", *port))
.map_err(|error| last = Some(error))
.ok()
})
.ok_or_else(|| ServeError::Bind {
port: ports[0],
source: last.unwrap_or_else(|| std::io::ErrorKind::AddrInUse.into()),
})?;
Ok((listener, threads))
}
#[derive(Debug)]
pub enum ServeError {
Config(crate::config::ConfigError),
Bind {
port: u16,
source: std::io::Error,
},
}
impl std::fmt::Display for ServeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(error) => write!(f, "{error}"),
Self::Bind { port, source } => {
write!(
f,
"Port {port} is in use; set PORT to another one ({source})."
)
}
}
}
}
impl std::error::Error for ServeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Config(error) => Some(error),
Self::Bind { source, .. } => Some(source),
}
}
}
pub fn serve_on(listener: TcpListener, routes: Router, threads: usize) {
serve_until(listener, routes, threads, &Shutdown::new(), Duration::ZERO);
}
#[derive(Clone, Debug, Default)]
pub struct Shutdown(Arc<ShutdownState>);
#[derive(Debug, Default)]
struct ShutdownState {
stopped: AtomicBool,
address: OnceLock<SocketAddr>,
}
impl Shutdown {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn stop(&self) {
self.0.stopped.store(true, Ordering::SeqCst);
if let Some(address) = self.0.address.get() {
let _ = TcpStream::connect_timeout(address, Duration::from_secs(1));
}
}
#[must_use]
pub fn is_stopped(&self) -> bool {
self.0.stopped.load(Ordering::SeqCst)
}
}
pub fn serve_until(
listener: TcpListener,
routes: Router,
threads: usize,
shutdown: &Shutdown,
grace: Duration,
) -> bool {
if let Ok(mut address) = listener.local_addr() {
if address.ip().is_unspecified() {
address.set_ip(match address.ip() {
IpAddr::V4(_) => Ipv4Addr::LOCALHOST.into(),
IpAddr::V6(_) => Ipv6Addr::LOCALHOST.into(),
});
}
let _ = shutdown.0.address.set(address);
}
let threads = threads.max(1);
let routes = Arc::new(routes);
let (send, receive) = std::sync::mpsc::sync_channel::<Connection>(threads * 4);
let queue = Arc::new(Queue {
send,
waiting: AtomicUsize::new(0),
open: Arc::new(AtomicUsize::new(0)),
shutdown: shutdown.clone(),
});
let receive = Arc::new(std::sync::Mutex::new(receive));
for _ in 0..threads {
let (routes, queue, receive) = (
Arc::clone(&routes),
Arc::clone(&queue),
Arc::clone(&receive),
);
std::thread::spawn(move || {
loop {
let next = receive
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.recv();
let Ok(connection) = next else { return };
queue.waiting.fetch_sub(1, Ordering::Relaxed);
let _ = catch_unwind(AssertUnwindSafe(|| {
serve_connection(connection, &routes, &queue);
}));
}
});
}
for stream in listener.incoming() {
if shutdown.is_stopped() {
break;
}
let Ok(stream) = stream else { continue };
let _ = stream.set_nodelay(true);
queue.waiting.fetch_add(1, Ordering::Relaxed);
queue.open.fetch_add(1, Ordering::SeqCst);
let connection = Connection {
reader: BufReader::new(stream),
idle_since: Instant::now(),
served: 0,
_open: Open(Arc::clone(&queue.open)),
};
if queue.send.send(connection).is_err() {
return false;
}
}
drop(listener);
let deadline = Instant::now() + grace;
while queue.open.load(Ordering::SeqCst) > 0 {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
true
}
const IDLE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_REQUESTS: usize = 1000;
const IDLE_POLL: Duration = Duration::from_millis(100);
struct Connection {
reader: BufReader<TcpStream>,
idle_since: Instant,
served: usize,
_open: Open,
}
struct Open(Arc<AtomicUsize>);
impl Drop for Open {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
struct Queue {
send: SyncSender<Connection>,
waiting: AtomicUsize,
open: Arc<AtomicUsize>,
shutdown: Shutdown,
}
impl Queue {
fn requeue(&self, connection: Connection) {
self.waiting.fetch_add(1, Ordering::Relaxed);
if self.send.try_send(connection).is_err() {
self.waiting.fetch_sub(1, Ordering::Relaxed);
}
}
fn others_waiting(&self) -> bool {
self.waiting.load(Ordering::Relaxed) > 0
}
}
enum Wait {
Ready,
Closed,
Yield,
}
fn serve_connection(mut connection: Connection, routes: &Router, queue: &Queue) {
loop {
match next_request(&mut connection, queue) {
Wait::Ready => {}
Wait::Closed => return,
Wait::Yield => return queue.requeue(connection),
}
connection.served += 1;
if !respond(&mut connection, routes, &queue.shutdown) {
return;
}
connection.idle_since = Instant::now();
if queue.others_waiting() {
return queue.requeue(connection);
}
}
}
fn next_request(connection: &mut Connection, queue: &Queue) -> Wait {
let _ = connection
.reader
.get_ref()
.set_read_timeout(Some(IDLE_POLL));
loop {
match connection.reader.fill_buf() {
Ok([]) => return Wait::Closed,
Ok(_) => return Wait::Ready,
Err(problem)
if matches!(
problem.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
if connection.idle_since.elapsed() >= IDLE_TIMEOUT || queue.shutdown.is_stopped() {
return Wait::Closed;
}
if queue.others_waiting() {
return Wait::Yield;
}
}
Err(problem) if problem.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => return Wait::Closed,
}
}
}
fn respond(connection: &mut Connection, routes: &Router, shutdown: &Shutdown) -> bool {
let stream = connection.reader.get_ref();
let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
let peer = stream.peer_addr().ok().map(|address| address.ip());
let parsed = request::parse(&mut connection.reader, peer, &|request| {
routes.limit_for(request)
});
let last = connection.served >= MAX_REQUESTS;
let (response, keep, head) = match parsed {
Ok(request) => {
let response = catch_unwind(AssertUnwindSafe(|| routes.handle(&request)))
.unwrap_or_else(|panic| {
let reason = panic_reason(&*panic);
let reference = request.reference();
crate::log::with_reference(reference, || {
Log::error(format_args!(
"{} {} panicked: {reason}",
request.method, request.path
));
routes
.finish(error(500))
.with_header("x-request-id", &reference.to_string())
})
});
(
response,
request.keep_alive && !last && !shutdown.is_stopped(),
request.method == "HEAD",
)
}
Err(response) => (routes.finish(response), false, false),
};
let mut stream = connection.reader.get_ref();
let mut text = format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n{}",
response.status,
reason(response.status),
response.content_type,
response.body.len(),
if keep { "" } else { "Connection: close\r\n" }
);
for (name, value) in &response.headers {
let _ = std::fmt::Write::write_fmt(&mut text, format_args!("{name}: {value}\r\n"));
}
text.push_str("\r\n");
let written = stream.write_all(text.as_bytes()).and_then(|()| {
if head {
Ok(())
} else {
stream.write_all(&response.body)
}
});
keep && written.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "db")]
fn console_app(commands: &'static [Command]) -> App {
App {
logging: crate::log::Settings::from_config,
database: crate::db::Settings::from_config,
filesystems: |_| unreachable!("no disks in console tests"),
migrations: Vec::new,
seeders: Vec::new,
routes: |router, _, _| router,
commands,
#[cfg(feature = "auth")]
roles: &["admin", "user"],
#[cfg(feature = "mail")]
mail: crate::mail::Settings::from_config,
#[cfg(feature = "queue")]
jobs: Vec::new,
#[cfg(feature = "schedule")]
schedule: &[],
}
}
#[cfg(feature = "db")]
#[test]
fn app_commands_run_with_args_and_return_their_status() {
const EXIT: Command = Command {
name: "exit:with",
description: "Exit with the status given",
run: |args, config, db| {
assert_eq!(config.get("DB_DATABASE"), Some(":memory:"));
db.migrate(&[]).unwrap();
args.first().map_or(-1, |status| status.parse().unwrap())
},
};
let app = console_app(&[EXIT]);
app.check_commands();
let config = crate::config::Config::parse("DB_DATABASE=:memory:\n");
let db = crate::db::Db::connect(&(app.database)(&config));
let run = |name: &str, args: &[&str]| {
let args: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
app.command(&config, &db, name, &args)
};
assert_eq!(run("exit:with", &["7"]), 7);
assert_eq!(run("exit:with", &["0"]), 0);
assert_eq!(run("exit:with", &[]), -1);
assert_eq!(run("nope", &[]), 2, "unknown, not a panic");
assert_eq!(run("migrate", &[]), 0, "built-ins still run");
assert_eq!(run("list", &[]), 0);
let list = app.list();
assert!(
list.contains(" exit:with Exit with the status given\n"),
"{list}"
);
assert!(list.contains(" migrate:rollback "), "{list}");
assert!(!list.contains(" help "), "{list}");
assert_eq!(
list.contains("user:create"),
cfg!(feature = "auth"),
"{list}"
);
}
#[cfg(feature = "queue")]
#[test]
fn queue_retry_and_forget_report_unknown_ids() {
let app = console_app(&[]);
let config = crate::config::Config::parse("DB_DATABASE=:memory:\n");
let db = crate::db::Db::connect(&(app.database)(&config));
let run = |name: &str, args: &[&str]| {
let args: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
app.command(&config, &db, name, &args)
};
assert_eq!(run("queue:forget", &["all"]), 0, "none is fine");
db.with(|c| {
c.execute(
"INSERT INTO failed_jobs (name, payload, error, failed_at) VALUES ('mail', 'p', 'x', 0)",
&[],
)
})
.unwrap();
assert_eq!(run("queue:retry", &[]), 2, "usage");
assert_eq!(run("queue:retry", &["one"]), 2, "usage");
assert_eq!(run("queue:forget", &["99"]), 1, "unknown id");
assert_eq!(run("queue:retry", &["1"]), 0);
assert_eq!(run("queue:retry", &["1"]), 1, "moved already");
assert!(app.list().contains("queue:forget"));
}
#[cfg(feature = "db")]
#[test]
#[should_panic(expected = "app command \"migrate\" is already taken")]
fn app_commands_cannot_shadow_built_ins() {
const MIGRATE: Command = Command {
name: "migrate",
description: "Not the real one",
run: |_, _, _| 0,
};
console_app(&[MIGRATE]).check_commands();
}
#[cfg(feature = "db")]
#[test]
#[should_panic(expected = "app command \"greet\" is already taken")]
fn app_commands_have_one_name_each() {
const GREET: Command = Command {
name: "greet",
description: "Say hello",
run: |_, _, _| 0,
};
console_app(&[GREET, GREET]).check_commands();
}
#[test]
fn slow_requests_do_not_hold_up_the_rest() {
use std::io::Read;
use std::time::Instant;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let routes = Router::new().get("/slow", |_| {
std::thread::sleep(Duration::from_millis(300));
Response::text(200, "done")
});
std::thread::spawn(move || serve_on(listener, routes, 4));
let get = move || {
let mut stream = TcpStream::connect(address).unwrap();
stream
.write_all(b"GET /slow HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
.unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
answer
};
let started = Instant::now();
let clients: Vec<_> = (0..4).map(|_| std::thread::spawn(get)).collect();
for client in clients {
assert!(client.join().unwrap().ends_with("done"));
}
assert!(
started.elapsed() < Duration::from_millis(900),
"{:?}",
started.elapsed()
);
let _idle = TcpStream::connect(address).unwrap();
let started = Instant::now();
assert!(get().ends_with("done"));
assert!(started.elapsed() < Duration::from_secs(2));
}
#[test]
fn connections_stay_open_and_idle_ones_make_way() {
use std::io::Read;
use std::time::Instant;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let routes = Router::new().get("/hi", |_| Response::text(200, "hi"));
std::thread::spawn(move || serve_on(listener, routes, 1));
let mut open = TcpStream::connect(address).unwrap();
open.write_all(b"GET /hi HTTP/1.1\r\n\r\nGET /hi HTTP/1.1\r\n\r\n")
.unwrap();
open.write_all(b"HEAD /hi HTTP/1.1\r\n\r\nGET /hi HTTP/1.1\r\nConnection: close\r\n\r\n")
.unwrap();
let mut answers = String::new();
open.read_to_string(&mut answers).unwrap();
assert_eq!(answers.matches("HTTP/1.1 200 OK").count(), 3, "{answers}");
assert_eq!(answers.matches("Connection: close").count(), 1, "{answers}");
let last = &answers[answers.rfind("HTTP/1.1").unwrap()..];
assert!(last.contains("Connection: close\r\n"), "{answers}");
assert!(last.ends_with("\r\n\r\nhi"), "{answers}");
assert!(answers.contains("405 Method Not Allowed"), "{answers}");
assert!(!answers.contains('<'), "HEAD sent a body: {answers}");
let mut idle = TcpStream::connect(address).unwrap();
idle.write_all(b"GET /hi HTTP/1.1\r\n\r\n").unwrap();
let mut first = [0; 16];
idle.read_exact(&mut first).unwrap();
let started = Instant::now();
let mut other = TcpStream::connect(address).unwrap();
other
.write_all(b"GET /hi HTTP/1.1\r\nConnection: close\r\n\r\n")
.unwrap();
let mut answer = String::new();
other.read_to_string(&mut answer).unwrap();
assert!(answer.ends_with("hi"), "{answer}");
assert!(
started.elapsed() < Duration::from_secs(1),
"{:?}",
started.elapsed()
);
}
#[test]
fn stop_finishes_requests_in_flight_closes_idle_ones_and_returns() {
use std::io::Read;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let routes = Router::new()
.get("/hi", |_| Response::text(200, "hi"))
.get("/slow", |_| {
std::thread::sleep(Duration::from_millis(300));
Response::text(200, "done")
});
let shutdown = Shutdown::new();
let handle = shutdown.clone();
let server = std::thread::spawn(move || {
serve_until(listener, routes, 4, &handle, Duration::from_secs(5))
});
let mut idle = TcpStream::connect(address).unwrap();
idle.write_all(b"GET /hi HTTP/1.1\r\n\r\n").unwrap();
let mut first = [0; 16];
idle.read_exact(&mut first).unwrap();
let mut slow = TcpStream::connect(address).unwrap();
slow.write_all(b"GET /slow HTTP/1.1\r\n\r\n").unwrap();
std::thread::sleep(Duration::from_millis(100));
let stopped = Instant::now();
shutdown.stop();
let mut answer = String::new();
slow.read_to_string(&mut answer).unwrap();
assert!(answer.contains("Connection: close\r\n"), "{answer}");
assert!(answer.ends_with("done"), "{answer}");
let mut rest = Vec::new();
idle.read_to_end(&mut rest).unwrap();
assert!(
server.join().unwrap(),
"connections left open after the drain"
);
assert!(
stopped.elapsed() < Duration::from_secs(2),
"{:?}",
stopped.elapsed()
);
assert!(TcpStream::connect(address).is_err(), "still accepting");
}
#[test]
fn requests_carry_a_reference_into_logs_headers_and_error_pages() {
let routes = Router::new()
.get("/seen", |request| {
assert_eq!(crate::log::reference(), Some(request.reference()));
Response::text(200, "ok")
})
.get("/broken", |_| error(500));
let request = Request::get("/seen");
let response = routes.handle(&request);
assert_eq!(
response.header("x-request-id"),
Some(request.reference().to_string().as_str())
);
assert_eq!(
crate::log::reference(),
None,
"the scope ends with the request"
);
assert_ne!(Request::get("/seen").reference(), request.reference());
assert_eq!(
Request::get("/seen"),
request,
"requests compare by what was sent"
);
let request = Request::get("/broken");
let page = String::from_utf8(routes.handle(&request).body).unwrap();
assert!(page.contains(&format!("Reference: <code>{}</code>", request.reference())));
let outside = String::from_utf8(error(500).body).unwrap();
assert!(!outside.contains("Reference:"));
}
#[test]
fn asset_refuses_paths_outside_dist() {
assert_eq!(asset("/../Cargo.toml").status, 404);
assert_eq!(asset("/assets/../../Cargo.toml").status, 404);
assert_eq!(asset("//etc/passwd").status, 404);
}
#[test]
fn asset_types_cover_every_image_upload() {
for (file, expected) in [
("a.jpg", "image/jpeg"),
("a.JPEG", "image/jpeg"),
("a.png", "image/png"),
("a.gif", "image/gif"),
("a.webp", "image/webp"),
("a.pdf", "application/pdf"),
("a", "application/octet-stream"),
] {
assert_eq!(content_type(Path::new(file)), expected, "{file}");
}
}
#[test]
fn router_matches_method_and_path_then_falls_back() {
let routes = Router::new()
.get("/hello", |_| json("{}"))
.post("/hello", |_| Response::text(201, "created"));
assert_eq!(routes.handle(&Request::get("/hello")).status, 200);
assert_eq!(routes.handle(&Request::post("/hello")).status, 201);
assert_eq!(routes.handle(&Request::get("/missing.css")).status, 404);
assert_eq!(routes.handle(&Request::new("DELETE", "/hello")).status, 405);
}
#[test]
fn errors_fall_back_to_the_built_in_page() {
let response = error(418);
assert_eq!(response.status, 418);
assert_eq!(response.content_type, "text/html; charset=utf-8");
assert!(
String::from_utf8(response.body)
.unwrap()
.contains("Client Error")
);
assert_eq!(Router::new().handle(&Request::get("/missing")).status, 404);
}
#[test]
fn middleware_runs_in_order_and_groups_stay_scoped() {
let tag = |name: &'static str| {
move |request: &Request, next: Next| {
let response = next(request);
let trail = response.header("x-trail").unwrap_or_default().to_owned();
let mut response = response;
response.headers.retain(|(key, _)| key != "x-trail");
response.with_header("x-trail", &format!("{name}{trail}"))
}
};
let routes = Router::new()
.middleware(tag("a"))
.middleware(tag("b"))
.group(|group| group.middleware(tag("g")).get("/in", |_| json("{}")))
.get("/out", |_| json("{}"));
let trail = |path| {
routes
.handle(&Request::get(path))
.header("x-trail")
.map(str::to_owned)
};
assert_eq!(trail("/in").as_deref(), Some("abg"));
assert_eq!(trail("/out").as_deref(), Some("ab"));
assert_eq!(trail("/missing").as_deref(), Some("ab"));
let stop = Router::new()
.middleware(|_: &Request, _: Next| Response::text(401, "no"))
.get("/", |_| json("{}"));
assert_eq!(stop.handle(&Request::get("/")).status, 401);
}
#[test]
fn params_match_one_segment() {
let routes = Router::new().get("/blog/{slug}", |request| {
Response::text(200, request.param("slug").unwrap_or_default())
});
let body = |path| {
let response = routes.handle(&Request::get(path));
(
response.status,
String::from_utf8(response.body).unwrap_or_default(),
)
};
assert_eq!(
body("/blog/hello-clamp?x=1"),
(200, "hello-clamp".to_owned())
);
assert_eq!(body("/blog/").0, 404);
assert_eq!(body("/blog/a/b").0, 404);
assert_eq!(body("/blog").0, 404);
assert_eq!(params("/a/{x}/c/{y}", "/a/1/c/2").unwrap().len(), 2);
}
#[test]
fn headers_cannot_inject_lines() {
let response = redirect("/next\r\nSet-Cookie: evil=1");
assert_eq!(response.status, 302);
assert_eq!(
response.header("location"),
Some("/next Set-Cookie: evil=1")
);
}
#[test]
fn escape_and_security_headers() {
assert_eq!(
escape(r#"<a href="x" title='y'>&</a>"#),
"<a href="x" title='y'>&</a>"
);
let routes = Router::new()
.middleware(security_headers)
.get("/", |_| json("{}"));
let response = routes.handle(&Request::get("/"));
assert_eq!(response.header("x-frame-options"), Some("DENY"));
assert_eq!(response.header("x-content-type-options"), Some("nosniff"));
assert_eq!(
response.header("strict-transport-security"),
Some("max-age=31536000")
);
let policy = response.header("content-security-policy").unwrap();
assert!(
policy.contains("script-src 'self';") && !policy.contains("script-src 'self' 'unsafe")
);
assert!(policy.contains("frame-ancestors 'none'"));
let own = Router::new().middleware(security_headers).get("/", |_| {
json("{}").with_header("Content-Security-Policy", "default-src 'none'")
});
let response = own.handle(&Request::get("/"));
let policies: Vec<_> = response
.headers
.iter()
.filter(|(name, _)| name == "Content-Security-Policy")
.collect();
assert_eq!(
policies.len(),
1,
"a route's own policy is kept, not doubled"
);
assert_eq!(policies[0].1, "default-src 'none'");
}
#[test]
fn package_middleware_stays_on_package_routes() {
struct Locked;
impl Package for Locked {
fn routes(self, router: Router) -> Router {
router
.middleware(|_: &Request, _: Next| Response::text(401, "no"))
.get("/locked", |_| json("{}"))
}
}
let routes = Router::new().package(Locked).get("/open", |_| json("{}"));
assert_eq!(routes.handle(&Request::get("/locked")).status, 401);
assert_eq!(routes.handle(&Request::get("/open")).status, 200);
}
#[test]
fn package_view_prefers_the_app_override_and_extends_app_layouts() {
let public = std::env::temp_dir().join(format!("clamp-pkg-{}", std::process::id()));
let body = |response: Response| String::from_utf8(response.body).unwrap();
let data: [(&str, &dyn ToValue); 1] = [("title", &"<Hi>")];
assert_eq!(
body(package_view_in(
&public,
"blog",
"index",
"<h1>{{ title }}</h1>",
&data
)),
"<h1><Hi></h1>"
);
let dir = public.join("build/views/vendor/blog");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("index.html"),
"@extends('layouts.app')@section('main')<h2>{{ title }}</h2>@endsection",
)
.unwrap();
fs::create_dir_all(public.join("build/views/layouts")).unwrap();
fs::write(
public.join("build/views/layouts/app.html"),
"<main>@yield('main')</main>",
)
.unwrap();
let response = package_view_in(&public, "blog", "index", "<h1>{{ title }}</h1>", &data);
fs::remove_dir_all(&public).unwrap();
assert_eq!(response.status, 200);
assert_eq!(body(response), "<main><h2><Hi></h2></main>");
}
#[test]
fn forbidden_is_answered_as_not_found() {
let routes = Router::new()
.middleware(security_headers)
.get("/secret", |_| Response::text(403, "no"));
let without_reference = |mut response: Response| {
assert!(response.header("x-request-id").is_some());
response.headers.retain(|(name, _)| name != "x-request-id");
response
};
let hidden = without_reference(routes.handle(&Request::get("/secret")));
let missing = without_reference(routes.handle(&Request::get("/missing")));
assert_eq!(hidden.status, 404);
assert_eq!(
hidden, missing,
"indistinguishable from a real 404, headers included"
);
}
#[test]
fn forbidden_can_be_revealed() {
let routes = Router::new()
.reveal_forbidden()
.get("/own", |_| Response::text(403, "own_listing"));
let response = routes.handle(&Request::get("/own"));
assert_eq!(response.status, 403);
assert_eq!(response.body, b"own_listing");
}
#[test]
fn bad_settings_and_busy_ports_are_errors() {
use crate::config::Config;
assert!(matches!(
bind(&Config::parse("PORT=eighty")),
Err(ServeError::Config(_))
));
assert!(matches!(
bind(&Config::parse("PORT=0\nWEB_THREADS=many")),
Err(ServeError::Config(_))
));
let taken = TcpListener::bind("127.0.0.1:0").unwrap();
let port = taken.local_addr().unwrap().port();
let busy = bind(&Config::parse(&format!("PORT={port}"))).unwrap_err();
assert!(matches!(busy, ServeError::Bind { port: p, .. } if p == port));
let (_listener, threads) = bind(&Config::parse("PORT=0\nWEB_THREADS=3")).unwrap();
assert_eq!(threads, 3);
}
#[test]
fn up_answers_ok() {
let response = Router::new().up("/up").handle(&Request::get("/up"));
assert_eq!(
(response.status, response.body),
(200, br#"{"status":"ok"}"#.to_vec())
);
}
#[test]
fn json_status_and_public_reason() {
let created = json_status(201, "{}");
assert_eq!(
(created.status, created.content_type),
(201, "application/json")
);
assert_eq!(reason(404), "Not Found");
}
#[test]
fn a_path_under_other_methods_is_405_with_allow() {
let routes = Router::new()
.get("/items", |_| Response::text(200, "list"))
.post("/items", |_| Response::text(201, "made"))
.get("/items/{id}", |_| Response::text(200, "one"));
let response = routes.handle(&Request::new("DELETE", "/items"));
assert_eq!(response.status, 405);
assert_eq!(response.header("allow"), Some("GET, POST"));
let response = routes.handle(&Request::post("/items/3"));
assert_eq!(
(response.status, response.header("allow")),
(405, Some("GET"))
);
assert_eq!(routes.handle(&Request::post("/nope")).status, 404);
assert_eq!(routes.handle(&Request::get("/nope")).status, 404);
}
#[test]
fn json_errors_render_problem_json() {
let routes = Router::new()
.json_errors()
.get("/boom", |_| -> Result { Err("secret detail".into()) })
.get("/plain", |_| Response::text(404, "mine"))
.post("/only", |_| Response::text(200, "ok"));
let check = |response: Response, status: u16, title: &str| {
assert_eq!(response.status, status);
assert_eq!(response.content_type, "application/problem+json");
assert_eq!(
response.body_text(),
format!(
r#"{{"type":"about:blank","title":"{title}","status":{status},"detail":"{title}"}}"#
)
);
};
check(routes.handle(&Request::get("/missing")), 404, "Not Found");
let response = routes.handle(&Request::get("/only"));
assert_eq!(response.header("allow"), Some("POST"));
check(response, 405, "Method Not Allowed");
check(
routes.handle(&Request::get("/boom")),
500,
"Internal Server Error",
);
assert_eq!(routes.handle(&Request::get("/plain")).body, b"mine");
let hidden = Router::new()
.json_errors()
.get("/x", |_| Response::text(403, ""))
.handle(&Request::get("/x"));
assert_eq!(hidden.status, 404);
assert_eq!(hidden.content_type, "application/problem+json");
}
#[test]
fn json_errors_can_cover_one_group() {
let routes = Router::new()
.group(|api| {
api.json_errors()
.get("/api/boom", |_| -> Result { Err("x".into()) })
})
.get("/boom", |_| -> Result { Err("x".into()) });
assert_eq!(
routes.handle(&Request::get("/api/boom")).content_type,
"application/problem+json"
);
assert_eq!(
routes.handle(&Request::get("/boom")).content_type,
"text/html; charset=utf-8"
);
}
#[test]
fn problem_escapes_its_detail() {
assert_eq!(
problem(400, "a \"b\"\n").body_text(),
r#"{"type":"about:blank","title":"Bad Request","status":400,"detail":"a \"b\"\u000a"}"#
);
}
#[test]
fn body_limits_come_from_the_group_or_router() {
let ok = |_: &Request| Response::text(200, "ok");
let routes = Router::new()
.body_limit(100)
.post("/router", ok)
.group(|small| small.body_limit(10).post("/group", ok))
.group(|inherits| inherits.post("/inherits", ok));
let limit = |path: &str| routes.limit_for(&Request::post(path));
assert_eq!(
(limit("/router"), limit("/group"), limit("/inherits")),
(100, 10, 100)
);
assert_eq!(limit("/unknown"), 100);
assert_eq!(Router::new().limit_for(&Request::post("/")), MAX_BODY);
}
#[test]
fn an_oversized_body_is_refused_after_auth_and_never_read() {
use std::io::Read;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let routes = Router::new()
.json_errors()
.body_limit(16)
.middleware(|request, next| {
if request.header("authorization").is_some() {
next(request)
} else {
problem(401, "sign in")
}
})
.post("/upload", |_| Response::text(200, "stored"));
std::thread::spawn(move || serve_on(listener, routes, 2));
let send = |head: &str| {
let mut stream = TcpStream::connect(address).unwrap();
let started = Instant::now();
let request = format!("POST /upload HTTP/1.1\r\n{head}Content-Length: 9999\r\n\r\n");
stream.write_all(request.as_bytes()).unwrap();
let mut answer = String::new();
stream.read_to_string(&mut answer).unwrap();
assert!(started.elapsed() < Duration::from_secs(5), "{answer}");
answer
};
assert!(send("").starts_with("HTTP/1.1 401"));
let refused = send("Authorization: Bearer x\r\n");
assert!(
refused.starts_with("HTTP/1.1 413 Content Too Large"),
"{refused}"
);
assert!(refused.contains("application/problem+json"), "{refused}");
}
#[test]
fn typed_params_only_match_what_parses() {
let routes = Router::new()
.get("/users/me", |_| Response::text(200, "me"))
.get("/users/{id:u64}", |request| {
let next = request.param_as::<u64>("id").unwrap() + 1;
Response::text(200, &next.to_string())
})
.get("/tags/{name}", |request| {
Response::text(200, &format!("{:?}", request.param_as::<u8>("name")))
});
let body = |path: &str| routes.handle(&Request::get(path)).body_text().into_owned();
assert_eq!(body("/users/41"), "42");
assert_eq!(body("/users/me"), "me");
assert_eq!(routes.handle(&Request::get("/users/-1")).status, 404);
assert_eq!(routes.handle(&Request::get("/users/x")).status, 404);
assert_eq!(body("/tags/7"), "Some(7)");
assert_eq!(body("/tags/seven"), "None");
}
#[test]
#[should_panic(expected = "unknown route parameter type")]
fn an_unknown_param_type_is_refused_when_declared() {
let _ = Router::new().get("/a/{id:uuid}", |_| Response::text(200, ""));
}
#[test]
fn extensions_carry_typed_values_from_middleware_to_handlers() {
struct Principal(&'static str);
let routes = Router::new()
.middleware(|request, next| {
let request = request.clone().with_extension(Principal("ann"));
next(
&request
.with_extension(Principal("bob"))
.with_extension(7_u8),
)
})
.get("/me", |request| {
let who = request.extension::<Principal>().map_or("nobody", |p| p.0);
Response::text(200, &format!("{who} {:?}", request.extension::<u8>()))
});
assert_eq!(routes.handle(&Request::get("/me")).body, b"bob Some(7)");
assert_eq!(Request::get("/").extension::<u8>(), None);
}
#[test]
fn json_sets_content_type() {
let response = json("{}");
assert_eq!(response.status, 200);
assert_eq!(response.content_type, "application/json");
}
}