use std::borrow::Cow;
use std::cell::Cell;
use std::fmt;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use serve_net::http_net::MethodSet;
use serve_net::{
Envelope, HttpHead, HttpReply, HttpRequest, HttpService, Method, Outcome, Scheme, Verdict,
WorkClass,
};
use crate::assets::{self, Assets};
use crate::cors::Cors;
use crate::csrf;
use crate::cx::{Out, is_local_target};
use crate::error::{self, BuildError, Problem};
use crate::headers::SecurityHeaders;
use crate::policy::{CachePolicy, Cacheable, Compression, Served, compressible};
use crate::route::{Access, BoxHandler, Group, Route};
use crate::router::{self, Captures, Trie};
use crate::url::{self, TrailingSlash};
use crate::{
Cx, Denial, DenialKind, Event, EventKind, Events, Fields, Gate, Identity, NoEvents, Open,
Output, Render, Request, RequestId, Result, Stage, TemplateId, Value,
};
pub const DEFAULT_PAGE_LIMIT: usize = 8 << 20;
const ERROR_STATUSES: [u16; 14] = [
400, 401, 403, 404, 405, 409, 410, 413, 421, 422, 429, 500, 501, 503,
];
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hook {
Routed,
Finish,
BeforeRender,
}
#[derive(Clone, Debug)]
pub struct Routes(Selector);
#[derive(Clone, Debug)]
enum Selector {
All,
Prefix(String),
Named(Vec<&'static str>),
}
impl Routes {
#[must_use]
pub fn all() -> Self {
Self(Selector::All)
}
#[must_use]
pub fn prefix(prefix: impl Into<String>) -> Self {
Self(Selector::Prefix(prefix.into()))
}
#[must_use]
pub fn named(names: &[&'static str]) -> Self {
Self(Selector::Named(names.to_vec()))
}
fn matches(&self, pattern: &str, name: Option<&str>) -> bool {
match &self.0 {
Selector::All => true,
Selector::Prefix(p) => {
let p = p.trim_end_matches('/');
pattern == p
|| pattern
.strip_prefix(p)
.is_some_and(|rest| rest.starts_with('/'))
}
Selector::Named(names) => name.is_some_and(|n| names.contains(&n)),
}
}
}
pub(crate) type HookFn<I> = Arc<dyn Fn(&mut Cx<'_, I>) -> Result + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Classified {
pub route: Option<RouteId>,
pub work: WorkClass,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct RouteId(pub u32);
pub(crate) struct Shared {
pub(crate) render: Option<Box<dyn Render>>,
pub(crate) checkpoint: Option<Box<dyn crate::Checkpoint>>,
pub(crate) events: Box<dyn Events>,
pub(crate) page_limit: usize,
pub(crate) base: Box<str>,
errors: Vec<(u16, TemplateId)>,
sign_in: Option<Box<str>>,
challenge: Box<str>,
wipe: bool,
security: SecurityHeaders,
pub(crate) csrf: Option<csrf_sec::Csrf>,
pub(crate) redirect_hosts: Vec<Box<str>>,
compression: Option<Compression>,
}
#[derive(Clone, Copy)]
pub(crate) struct At<'a> {
pub(crate) env: &'a Envelope,
pub(crate) tls: bool,
pub(crate) json: bool,
}
type ReadyFn = Arc<dyn Fn() -> bool + Send + Sync>;
#[derive(Clone)]
enum Health {
Live,
Ready(ReadyFn),
}
#[derive(Clone, Debug, Default)]
struct Site {
hosts: Vec<Box<str>>,
aliases: Vec<Box<str>>,
development: bool,
slash: TrailingSlash,
lowercase: bool,
https_only: bool,
}
struct Entry<G: Gate> {
handler: BoxHandler<G::Identity>,
access: Access<G::Rule>,
names: Box<[Box<str>]>,
pattern: Box<str>,
work: WorkClass,
template: Option<TemplateId>,
csrf: bool,
limits: form_net::Limits,
cors: Option<Arc<Cors>>,
cache: CachePolicy,
routed: Box<[HookFn<G::Identity>]>,
finish: Box<[HookFn<G::Identity>]>,
before_render: Box<[HookFn<G::Identity>]>,
}
struct Endpoint {
methods: [Option<u32>; 10],
allow: Box<str>,
set: MethodSet,
}
pub struct App<G: Gate = Open> {
gate: G,
shared: Shared,
site: Site,
trie: Trie,
endpoints: Vec<Endpoint>,
entries: Vec<Entry<G>>,
allow_all: Box<str>,
assets: Vec<Assets>,
health: Vec<(Box<str>, Health)>,
}
impl App<Open> {
#[must_use]
pub fn builder() -> Builder<Open> {
Builder::new(Open)
}
}
impl<G: Gate> App<G> {
#[must_use]
pub fn with_gate(gate: G) -> Builder<G> {
Builder::new(gate)
}
}
pub struct Builder<G: Gate> {
gate: G,
routes: Vec<Route<G::Identity, G::Rule>>,
hooks: Vec<(Hook, Routes, HookFn<G::Identity>)>,
site: Site,
base: String,
render: Option<Box<dyn Render>>,
checkpoint: Option<Box<dyn crate::Checkpoint>>,
events: Box<dyn Events>,
page_limit: usize,
sign_in: Option<String>,
challenge: String,
wipe: bool,
security: SecurityHeaders,
assets: Vec<Assets>,
health: Vec<(String, Health)>,
csrf: Option<csrf_sec::Csrf>,
redirect_hosts: Vec<Box<str>>,
compression: Option<Compression>,
}
impl<G: Gate> Builder<G> {
fn new(gate: G) -> Self {
Self {
gate,
routes: Vec::new(),
hooks: Vec::new(),
site: Site::default(),
base: String::new(),
render: None,
checkpoint: None,
events: Box::new(NoEvents),
page_limit: DEFAULT_PAGE_LIMIT,
sign_in: None,
challenge: "Session".into(),
wipe: false,
security: SecurityHeaders::default(),
assets: Vec::new(),
health: Vec::new(),
csrf: None,
redirect_hosts: Vec::new(),
compression: None,
}
}
#[must_use]
pub fn compress(mut self, compression: Compression) -> Self {
self.compression = Some(compression);
self
}
#[must_use]
pub fn csrf(mut self, csrf: csrf_sec::Csrf) -> Self {
self.csrf = Some(csrf);
self
}
#[must_use]
pub fn redirect_hosts(mut self, hosts: &[&str]) -> Self {
self.redirect_hosts.extend(
hosts
.iter()
.map(|h| h.to_ascii_lowercase().into_boxed_str()),
);
self
}
#[must_use]
pub fn assets(mut self, assets: Assets) -> Self {
self.assets.push(assets);
self
}
#[must_use]
pub fn health(mut self, path: &str) -> Self {
self.health.push((path.to_owned(), Health::Live));
self
}
#[must_use]
pub fn ready(mut self, path: &str, check: impl Fn() -> bool + Send + Sync + 'static) -> Self {
self.health
.push((path.to_owned(), Health::Ready(Arc::new(check))));
self
}
#[must_use]
pub fn security_headers(mut self, policy: SecurityHeaders) -> Self {
self.security = policy;
self
}
#[must_use]
pub fn route(mut self, route: Route<G::Identity, G::Rule>) -> Self {
self.routes.push(route);
self
}
#[must_use]
pub fn group(mut self, group: Group<G::Identity, G::Rule>) -> Self {
self.routes.extend(group.into_routes());
self
}
#[must_use]
pub fn hook<F>(mut self, hook: Hook, routes: Routes, f: F) -> Self
where
F: Fn(&mut Cx<'_, G::Identity>) -> Result + Send + Sync + 'static,
{
self.hooks.push((hook, routes, Arc::new(f)));
self
}
#[must_use]
pub fn host(mut self, host: &str) -> Self {
self.site.hosts.push(host.to_ascii_lowercase().into());
self
}
#[must_use]
pub fn alias(mut self, host: &str) -> Self {
self.site.aliases.push(host.to_ascii_lowercase().into());
self
}
#[must_use]
pub fn development(mut self) -> Self {
self.site.development = true;
self
}
#[must_use]
pub fn base(mut self, base: &str) -> Self {
self.base = base.to_owned();
self
}
#[must_use]
pub fn trailing_slash(mut self, policy: TrailingSlash) -> Self {
self.site.slash = policy;
self
}
#[must_use]
pub fn lowercase_paths(mut self) -> Self {
self.site.lowercase = true;
self
}
#[must_use]
pub fn https_only(mut self) -> Self {
self.site.https_only = true;
self
}
#[must_use]
pub fn sign_in(mut self, path: &str) -> Self {
self.sign_in = Some(path.to_owned());
self
}
#[must_use]
pub fn challenge(mut self, challenge: &str) -> Self {
challenge.clone_into(&mut self.challenge);
self
}
#[must_use]
pub fn render(mut self, engine: impl Render) -> Self {
self.render = Some(Box::new(engine));
self
}
#[must_use]
pub fn events(mut self, events: impl Events) -> Self {
self.events = Box::new(events);
self
}
#[must_use]
pub fn checkpoint(mut self, checkpoint: impl crate::Checkpoint) -> Self {
self.checkpoint = Some(Box::new(checkpoint));
self
}
#[must_use]
pub fn page_limit(mut self, bytes: usize) -> Self {
self.page_limit = bytes;
self
}
#[must_use]
pub fn wipe_buffers(mut self) -> Self {
self.wipe = true;
self
}
pub fn build(self) -> std::result::Result<App<G>, BuildError> {
let mut problems = Vec::new();
let mut problem = |subject: &str, message: String| {
problems.push(Problem {
subject: subject.to_owned(),
message,
});
};
let base = self.base.trim_end_matches('/');
if !base.is_empty() {
match url::normalize(base, self.site.lowercase) {
Ok(n) if n == base && !base.contains(['?', '#']) => {}
_ => problem(
"base",
format!("'{}' is not a canonical path such as '/blog'", self.base),
),
}
}
for h in self.site.hosts.iter().chain(&self.site.aliases) {
if !valid_host(h) {
problem("host", format!("'{h}' is not a host name or address"));
}
}
if self.site.https_only && self.site.hosts.is_empty() {
problem(
"https_only",
"redirecting to HTTPS needs a declared host; add .host(\"example.com\")".into(),
);
}
if let Some(s) = &self.sign_in {
if !is_local_target(s) {
problem("sign_in", format!("'{s}' is not a site-relative path"));
}
}
if self.challenge.is_empty() || !self.challenge.bytes().all(|b| (0x20..0x7f).contains(&b)) {
problem("challenge", "the challenge must be visible ASCII".into());
}
if !self.security.valid() {
problem(
"security_headers",
"every header value must be visible ASCII and not empty".into(),
);
}
let mut assets = self.assets;
for a in &mut assets {
let subject = format!("assets {}", a.mount);
if a.mount.is_empty()
|| !is_local_target(&a.mount)
|| url::normalize(&a.mount, self.site.lowercase).map_or(true, |n| n != a.mount)
{
problem(
&subject,
"the mount must be a canonical path such as '/static'".into(),
);
}
match std::fs::canonicalize(&a.dir) {
Ok(root) if root.is_dir() => a.root = root,
_ => problem(
&subject,
format!("'{}' is not a folder that exists", a.dir.display()),
),
}
for r in &self.routes {
if a.strip(&r.pattern).is_some() {
problem(
&r.pattern,
format!(
"the route is under the assets mount '{}' and can never run",
a.mount
),
);
}
}
}
for h in &self.redirect_hosts {
if !valid_host(h) {
problem(
"redirect_hosts",
format!("'{h}' is not a host name or address"),
);
}
}
let mut health = Vec::new();
for (p, h) in self.health {
if !is_local_target(&p) || url::normalize(&p, false).map_or(true, |n| n != p) {
problem(
"health",
format!("'{p}' is not a canonical site-relative path"),
);
}
health.push((p.into_boxed_str(), h));
}
let mut errors = Vec::new();
if let Some(r) = &self.render {
for s in ERROR_STATUSES {
if let Some(t) = r.template(&format!("error/{s}")) {
errors.push((s, t));
}
}
}
let mut trie = Trie::default();
let mut endpoints: Vec<Endpoint> = Vec::new();
let mut entries: Vec<Entry<G>> = Vec::new();
let mut all = MethodSet::EMPTY;
for r in self.routes {
let parts = match router::parse(&r.pattern) {
Ok(p) => p,
Err(e) => {
problem(&r.pattern, e.message());
continue;
}
};
if r.handlers.is_empty() {
problem(&r.pattern, "the route has no handler".into());
continue;
}
if matches!(r.access, Access::Undeclared) {
problem(
&r.pattern,
"the route declares no access; add .public() or .allow(rule)".into(),
);
continue;
}
let template = match (r.template, &self.render) {
(None, _) => None,
(Some(t), None) => {
problem(
&r.pattern,
format!("template '{t}' is declared but no render engine is set"),
);
None
}
(Some(t), Some(engine)) => {
let id = engine.template(t);
if id.is_none() {
problem(&r.pattern, format!("the engine has no template '{t}'"));
}
id
}
};
let ep = trie.insert(&parts, endpoints.len() as u32) as usize;
if ep == endpoints.len() {
endpoints.push(Endpoint {
methods: [None; 10],
allow: Box::from(""),
set: MethodSet::EMPTY,
});
}
let names: Box<[Box<str>]> = router::names(&parts).into();
if r.csrf_exempt.is_some_and(|reason| reason.trim().is_empty()) {
problem(&r.pattern, "a CSRF exemption needs its reason".into());
}
if let Some(why) = r.cors.as_deref().and_then(Cors::problem) {
problem(&r.pattern, why.into());
}
if let Err(e) = r.form_limits.validate() {
problem(&r.pattern, format!("the form limits are inconsistent: {e}"));
}
for (m, h) in r.handlers {
let subject = format!("{} {}", m.as_str().unwrap_or("?"), r.pattern);
if m == Method::Other || m == Method::Connect || m == Method::Trace {
problem(&subject, "this method cannot be routed".into());
continue;
}
let slot = &mut endpoints[ep].methods[m.index() as usize];
if slot.is_some() {
problem(
&subject,
"a handler for this method and path exists already".into(),
);
continue;
}
*slot = Some(entries.len() as u32);
let csrf = r.csrf_exempt.is_none() && !m.is_safe();
if csrf && self.csrf.is_none() {
problem(
&subject,
"an unsafe method needs CSRF protection; set Builder::csrf(..) or mark the route csrf_exempt(\"reason\")".into(),
);
}
let pick = |at: Hook| -> Box<[HookFn<G::Identity>]> {
self.hooks
.iter()
.filter(|(h, sel, _)| *h == at && sel.matches(&r.pattern, r.name))
.map(|(_, _, f)| Arc::clone(f))
.collect()
};
entries.push(Entry {
handler: h,
access: r.access.clone(),
names: names.clone(),
pattern: r.pattern.as_str().into(),
work: r.work,
template,
csrf,
limits: r.form_limits,
cors: r.cors.clone(),
cache: r.cache,
routed: pick(Hook::Routed),
finish: pick(Hook::Finish),
before_render: pick(Hook::BeforeRender),
});
}
}
for e in &mut endpoints {
let mut set = MethodSet::EMPTY;
for m in [
Method::Get,
Method::Head,
Method::Post,
Method::Put,
Method::Delete,
Method::Options,
Method::Patch,
] {
if e.methods[m.index() as usize].is_some() {
set = set.with(m);
}
}
if set.contains(Method::Get) {
set = set.with(Method::Head);
}
set = set.with(Method::Options);
e.set = set;
all = all.union(set);
let mut v = Vec::new();
set.write_allow(&mut v);
e.allow = String::from_utf8(v).unwrap_or_default().into();
}
if !problems.is_empty() {
return Err(BuildError::new(problems));
}
let mut v = Vec::new();
all.with(Method::Options).write_allow(&mut v);
Ok(App {
gate: self.gate,
shared: Shared {
render: self.render,
checkpoint: self.checkpoint,
events: self.events,
page_limit: self.page_limit,
base: base.into(),
errors,
sign_in: self.sign_in.map(Into::into),
challenge: self.challenge.into(),
wipe: self.wipe,
security: self.security,
csrf: self.csrf,
redirect_hosts: self.redirect_hosts,
compression: self.compression,
},
site: self.site,
trie,
endpoints,
entries,
allow_all: String::from_utf8(v).unwrap_or_default().into(),
assets,
health,
})
}
}
impl<G: Gate> fmt::Debug for Builder<G> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Builder")
.field("routes", &self.routes)
.field("site", &self.site)
.field("base", &self.base)
.finish_non_exhaustive()
}
}
fn valid_host(h: &str) -> bool {
let (host, port) = split_port(h);
let host_ok = if let Some(inner) = host.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
inner.parse::<std::net::Ipv6Addr>().is_ok()
} else {
!host.is_empty()
&& host.len() <= 253
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
};
host_ok && port.is_none_or(|p| !p.is_empty() && p.parse::<u16>().is_ok())
}
fn split_port(a: &str) -> (&str, Option<&str>) {
if a.starts_with('[') {
return match a.find(']') {
Some(i) => match a[i + 1..].strip_prefix(':') {
Some(p) => (&a[..=i], Some(p)),
None => (a, None),
},
None => (a, None),
};
}
match a.rsplit_once(':') {
Some((h, p)) => (h, Some(p)),
None => (a, None),
}
}
fn is_dev_host(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]") || host.ends_with(".localhost")
}
thread_local! {
static SCRATCH: Cell<Option<Box<Out>>> = const { Cell::new(None) };
}
const KEEP_CAPACITY: usize = 1 << 20;
enum Placed<'a> {
Here(Cow<'a, str>),
Redirect(String),
Refuse(u16, &'static str, Option<url::UrlError>),
}
impl<G: Gate> App<G> {
pub fn respond(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
self.serve(req, env, None, reply).outcome
}
pub fn serve(
&self,
req: &HttpRequest<'_>,
env: &Envelope,
who: Option<&G::Identity>,
reply: &mut HttpReply,
) -> Served {
let mut report = Served {
outcome: Outcome::Done,
route: None,
cache: Cacheable::No,
};
report.outcome = self.run(req, env, who, reply, &mut report);
report
}
pub fn respond_identified(
&self,
req: &HttpRequest<'_>,
env: &Envelope,
who: &G::Identity,
reply: &mut HttpReply,
) -> Outcome {
self.serve(req, env, Some(who), reply).outcome
}
#[must_use]
pub fn classify(&self, head: &HttpHead<'_>) -> Classified {
let none = Classified {
route: None,
work: WorkClass::Quick,
};
let Placed::Here(path) = self.place(head.authority(), head.path(), Scheme::Https, true)
else {
return none;
};
let Some((ep, _)) = self.trie.find(&path) else {
return none;
};
match self.entry_for(ep as usize, head.method()) {
Some(i) => Classified {
route: Some(RouteId(i)),
work: self.entries[i as usize].work,
},
None => none,
}
}
#[must_use]
pub fn gate(&self) -> &G {
&self.gate
}
fn entry_for(&self, ep: usize, m: Method) -> Option<u32> {
let methods = &self.endpoints[ep].methods;
match m {
Method::Other => None,
Method::Head => {
methods[Method::Head.index() as usize].or(methods[Method::Get.index() as usize])
}
m => methods[m.index() as usize],
}
}
fn place<'a>(
&self,
authority: Option<&'a str>,
raw: &'a str,
scheme: Scheme,
quiet: bool,
) -> Placed<'a> {
let site = &self.site;
let authority = authority.unwrap_or("");
let lower = authority.to_ascii_lowercase();
let lower = lower.trim_end_matches('.');
let host_only = split_port(lower).0.trim_end_matches('.');
let listed = |list: &[Box<str>]| {
list.iter().any(|h| {
let h = h.as_ref();
h == lower || (split_port(h).1.is_none() && h == host_only)
})
};
let dev = site.development && is_dev_host(host_only);
let mut target_host: Option<&str> = None;
if !site.hosts.is_empty() && !dev && !authority.is_empty() && !listed(&site.hosts) {
if listed(&site.aliases) {
target_host = site.hosts.first().map(AsRef::as_ref);
} else {
return Placed::Refuse(421, "host.unknown", None);
}
}
let mut https = scheme == Scheme::Https;
if site.https_only && !https && !dev {
https = true;
target_host = target_host.or(site.hosts.first().map(AsRef::as_ref));
}
let canon = match url::normalize(raw, site.lowercase) {
Ok(c) => c,
Err(e) => return Placed::Refuse(400, e.code(), Some(e)),
};
let base = self.shared.base.as_ref();
let c: &str = &canon;
let rel_start = if base.is_empty() {
0
} else if c == base {
c.len()
} else if c.strip_prefix(base).is_some_and(|r| r.starts_with('/')) {
base.len()
} else {
return Placed::Refuse(404, "site.outside_base", None);
};
let rel = &c[rel_start..];
let mut fixed: Option<String> = None;
if rel.is_empty() {
fixed = Some("/".into());
} else if rel.len() > 1 {
match site.slash {
TrailingSlash::Strip => {
if let Some(s) = rel.strip_suffix('/') {
fixed = Some(s.to_owned());
}
}
TrailingSlash::Add => {
let last = rel.rsplit('/').next().unwrap_or("");
if !rel.ends_with('/') && !last.contains('.') {
fixed = Some(format!("{rel}/"));
}
}
TrailingSlash::Ignore => {}
}
}
if target_host.is_none() && fixed.is_none() {
if let Cow::Borrowed(c) = canon {
let mut path = &c[rel_start..];
if site.slash == TrailingSlash::Ignore && path.len() > 1 {
path = path.strip_suffix('/').unwrap_or(path);
}
return Placed::Here(Cow::Borrowed(path));
}
}
if quiet {
return Placed::Refuse(0, "", None);
}
let rel_out = fixed.as_deref().unwrap_or(rel);
let mut loc = String::with_capacity(raw.len() + 32);
if let Some(h) = target_host {
loc.push_str(if https { "https://" } else { "http://" });
loc.push_str(h);
}
loc.push_str(base);
loc.push_str(rel_out);
Placed::Redirect(loc)
}
fn run(
&self,
http: &HttpRequest<'_>,
env: &Envelope,
who: Option<&G::Identity>,
reply: &mut HttpReply,
report: &mut Served,
) -> Outcome {
let method = http.method();
let raw_path = http.path();
let authority = http.head().authority();
let at = At {
env,
tls: self.tls(env, authority),
json: wants_json(http.header("accept")),
};
if raw_path == "*" {
if method == Method::Options {
reply.status(204).header("allow", self.allow_all.as_bytes());
self.secure(reply, at.tls, |_| false);
} else {
self.refuse(
400,
"url.asterisk",
EventKind::Refused,
None,
None,
at,
reply,
);
}
return Outcome::Done;
}
let host_raw = authority.unwrap_or("");
let placed = self.place(authority, raw_path, env.client().scheme(), false);
let path = match placed {
Placed::Here(path) => path,
Placed::Redirect(mut loc) => {
if let Some(q) = http.query() {
loc.push('?');
loc.push_str(q);
}
let status = if matches!(method, Method::Get | Method::Head) {
301
} else {
308
};
reply.status(status).header("location", loc.as_bytes());
self.secure(reply, at.tls, |_| false);
return Outcome::Done;
}
Placed::Refuse(status, code, src) => {
let src = src
.as_ref()
.map(|e| e as &(dyn std::error::Error + 'static));
self.refuse(status, code, EventKind::Refused, None, src, at, reply);
return Outcome::Done;
}
};
if let Some(outcome) = self.early(http, at, &path, reply) {
return outcome;
}
let mut req = Request {
http,
env,
path: &path,
host: host_raw,
route: None,
tls: at.tls,
json: at.json,
};
let owned;
let who: &G::Identity = match who {
Some(w) => w,
None => match self.gate.authenticate(&req) {
Ok(w) => {
owned = w;
&owned
}
Err(d) => {
self.deny(d, &req, reply);
return Outcome::Done;
}
},
};
let Some((ep, caps)) = self.trie.find(&path) else {
self.refuse(404, "route.none", EventKind::Refused, None, None, at, reply);
return Outcome::Done;
};
let endpoint = &self.endpoints[ep as usize];
let Some(idx) = self.entry_for(ep as usize, method) else {
if method == Method::Options {
if let (Some(origin), Some(wanted)) = (
http.header("origin"),
http.header("access-control-request-method"),
) {
let wanted = Method::from_bytes(wanted);
let cors = self
.entry_for(ep as usize, wanted)
.and_then(|i| self.entries[i as usize].cors.as_deref());
if let Some(cors) = cors {
let ok = cors.preflight(
origin,
wanted,
http.header("access-control-request-headers"),
endpoint.set,
reply,
);
if !ok {
self.event(EventKind::Refused, env, None, 204, "cors.denied", None);
}
self.secure(reply, at.tls, |_| false);
return Outcome::Done;
}
}
reply.status(204).header("allow", endpoint.allow.as_bytes());
self.secure(reply, at.tls, |_| false);
return Outcome::Done;
}
let status = if method == Method::Other { 501 } else { 405 };
let code = if status == 501 {
"method.unknown"
} else {
"method.not_allowed"
};
self.refuse(status, code, EventKind::Refused, None, None, at, reply);
if status == 405 {
reply.header("allow", endpoint.allow.as_bytes());
}
return Outcome::Done;
};
let entry = &self.entries[idx as usize];
req.route = Some(&entry.pattern);
report.route = Some(RouteId(idx));
if let Access::Rule(rule) = &entry.access {
if let Err(d) = self.gate.authorize(who, rule, &req) {
self.deny(d, &req, reply);
return Outcome::Done;
}
}
if let Some(cp) = &self.shared.checkpoint {
match cp.check(env, Stage::Authorized) {
Verdict::Pass => {}
Verdict::Deny { status } => {
self.refuse(
status,
"checkpoint.denied",
EventKind::Revoked,
req.route,
None,
at,
reply,
);
return Outcome::Done;
}
_ => {
self.refuse(
403,
"checkpoint.killed",
EventKind::Revoked,
req.route,
None,
at,
reply,
);
return Outcome::Close;
}
}
}
let binding = who.csrf_binding();
let csrf_state = match (&self.shared.csrf, entry.csrf) {
(Some(engine), true) => {
let scheme = if env.client().scheme() == Scheme::Https {
"https://"
} else {
"http://"
};
let own = format!("{scheme}{}", host_raw.to_ascii_lowercase());
match csrf::check(engine, http, binding, &own, &entry.limits) {
Ok(s) => s,
Err(code) => {
self.refuse(403, code, EventKind::Denied, req.route, None, at, reply);
return Outcome::Done;
}
}
}
(None, true) => {
self.refuse(
500,
"csrf.no_engine",
EventKind::Failed,
req.route,
None,
at,
reply,
);
return Outcome::Done;
}
(_, false) => csrf::State::Off,
};
let mut out = SCRATCH.with(Cell::take).unwrap_or_default();
out.reset();
let outcome = self.handle(
entry,
req,
who,
caps,
(csrf_state, binding),
&mut out,
reply,
&mut report.cache,
);
if self.shared.wipe {
out.wipe();
}
if out.body.capacity() <= KEEP_CAPACITY && out.values.capacity() <= KEEP_CAPACITY {
SCRATCH.with(|s| s.set(Some(out)));
}
outcome
}
fn early(
&self,
http: &HttpRequest<'_>,
at: At<'_>,
path: &str,
reply: &mut HttpReply,
) -> Option<Outcome> {
let method = http.method();
let get_like = matches!(method, Method::Get | Method::Head);
if let Some((_, h)) = self.health.iter().find(|(p, _)| p.as_ref() == path) {
if !get_like {
self.not_get(method, at, reply);
return Some(Outcome::Done);
}
let (status, text) = match h {
Health::Live => (200, "ok"),
Health::Ready(f) if f() => (200, "ready"),
Health::Ready(_) => (503, "not ready"),
};
reply.text(status, text).header("cache-control", "no-store");
self.secure(reply, at.tls, |_| false);
return Some(Outcome::Done);
}
let (assets, rel) = self
.assets
.iter()
.find_map(|a| a.strip(path).map(|rel| (a, rel)))?;
if !get_like {
self.not_get(method, at, reply);
return Some(Outcome::Done);
}
let Some(found) = assets::find(assets, rel, http.header("accept-encoding")) else {
self.refuse(
404,
"assets.none",
EventKind::Refused,
None,
None,
at,
reply,
);
return Some(Outcome::Done);
};
let outcome = assets::serve(assets, &found, http, reply);
self.secure(reply, at.tls, |_| false);
Some(outcome)
}
fn not_get(&self, method: Method, at: At<'_>, reply: &mut HttpReply) {
if method == Method::Options {
reply.status(204).header("allow", "GET, HEAD, OPTIONS");
self.secure(reply, at.tls, |_| false);
} else {
let (status, code) = if method == Method::Other {
(501, "method.unknown")
} else {
(405, "method.not_allowed")
};
self.refuse(status, code, EventKind::Refused, None, None, at, reply);
if status == 405 {
reply.header("allow", "GET, HEAD, OPTIONS");
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle(
&self,
entry: &Entry<G>,
req: Request<'_>,
who: &G::Identity,
caps: Captures,
(csrf_state, binding): (csrf::State, Option<&[u8]>),
out: &mut Out,
reply: &mut HttpReply,
cacheable: &mut Cacheable,
) -> Outcome {
let at = At {
env: req.env,
tls: req.tls,
json: req.json,
};
let route = req.route;
let http = req.http;
let guest = who.is_guest();
let (result, unverified) = {
let mut cx = Cx {
req,
who,
caps,
names: &entry.names,
out: &mut *out,
shared: &self.shared,
template: entry.template,
csrf: csrf_state,
binding,
limits: &entry.limits,
before_render: &entry.before_render,
rendering: false,
};
let result = catch_unwind(AssertUnwindSafe(|| -> Result {
for h in entry.routed.iter() {
h(&mut cx)?;
}
entry.handler.call(&mut cx)?;
for h in entry.finish.iter() {
h(&mut cx)?;
}
Ok(())
}));
(result, cx.csrf == csrf::State::Pending)
};
match result {
Ok(Ok(())) if unverified => {
out.reset();
self.refuse(
403,
"csrf.unverified",
EventKind::Failed,
route,
None,
at,
reply,
);
Outcome::Done
}
Ok(Ok(())) if !out.invalid => {
if !out.has("cache-control") {
if let Some(v) = entry.cache.value(guest) {
out.header("cache-control", v.as_bytes());
}
}
if let Some(c) = self.shared.compression {
compress(c, http.header("accept-encoding"), out);
}
*cacheable = match entry.cache {
CachePolicy::Guests { max_age } | CachePolicy::Public { max_age }
if guest
&& matches!(http.method(), Method::Get | Method::Head)
&& out.status == 200
&& !out.has("set-cookie")
&& out
.get("vary")
.is_none_or(|v| v.eq_ignore_ascii_case(b"accept-encoding")) =>
{
Cacheable::Guests { max_age }
}
_ => Cacheable::No,
};
reply.status(out.status);
for &(ns, ne, vs, ve) in &out.spans {
let name = core::str::from_utf8(&out.names[ns as usize..ne as usize])
.unwrap_or("x-invalid");
reply.header(name, &out.values[vs as usize..ve as usize]);
}
if !out.has("x-content-type-options") {
reply.header("x-content-type-options", "nosniff");
}
if let Some(cors) = &entry.cors {
cors.actual(http.header("origin"), reply);
}
self.secure(reply, at.tls, |n| out.has(n));
reply.body(&out.body);
Outcome::Done
}
Ok(Ok(())) => {
out.reset();
self.refuse(
500,
"reply.invalid_field",
EventKind::Failed,
route,
None,
at,
reply,
);
Outcome::Done
}
Ok(Err(e)) => {
out.reset();
let src = std::error::Error::source(&e);
self.refuse(
e.status(),
e.code(),
EventKind::Failed,
route,
src,
at,
reply,
);
Outcome::Done
}
Err(_) => {
out.reset();
self.refuse(
500,
"handler.panic",
EventKind::Panicked,
route,
None,
at,
reply,
);
Outcome::Done
}
}
}
fn deny(&self, d: Denial, req: &Request<'_>, reply: &mut HttpReply) {
let env = req.env;
let at = At {
env,
tls: req.tls,
json: req.json,
};
if d.kind() == DenialKind::SignIn {
if let Some(page) = &self.shared.sign_in {
if matches!(req.method(), Method::Get | Method::Head) {
let mut loc = String::with_capacity(64);
loc.push_str(&self.shared.base);
loc.push_str(page);
loc.push_str(if page.contains('?') {
"&next="
} else {
"?next="
});
let mut next = String::new();
next.push_str(&self.shared.base);
next.push_str(req.path);
if let Some(q) = req.query() {
next.push('?');
next.push_str(q);
}
encode_component(&next, &mut loc);
self.event(EventKind::Denied, env, req.route, 303, d.code(), None);
reply.status(303).header("location", loc.as_bytes());
self.secure(reply, at.tls, |_| false);
return;
}
}
}
let status = d.status_code();
self.refuse(
status,
d.code(),
EventKind::Denied,
req.route,
None,
at,
reply,
);
if status == 401 {
reply.header("www-authenticate", self.shared.challenge.as_bytes());
}
}
fn event(
&self,
kind: EventKind,
env: &Envelope,
route: Option<&str>,
status: u16,
code: &'static str,
source: Option<&(dyn std::error::Error + 'static)>,
) {
self.shared.events.event(&Event {
kind,
id: RequestId::of(env),
route,
status,
code,
source,
});
}
#[allow(clippy::too_many_arguments)]
fn refuse(
&self,
status: u16,
code: &'static str,
kind: EventKind,
route: Option<&str>,
source: Option<&(dyn std::error::Error + 'static)>,
at: At<'_>,
reply: &mut HttpReply,
) {
self.event(kind, at.env, route, status, code, source);
let reference = RequestId::of(at.env).to_string();
let mut body = Vec::with_capacity(512);
if at.json {
error::json_body(status, &reference, &mut body);
reply
.status(status)
.header("content-type", "application/json")
.header("x-content-type-options", "nosniff")
.header("cache-control", "no-store")
.body(&body);
self.secure(reply, at.tls, |_| false);
return;
}
let mut done = false;
if let (Some(engine), Some(&(_, t))) = (
self.shared.render.as_deref(),
self.shared.errors.iter().find(|(s, _)| *s == status),
) {
let fields = [
("status", Value::Int(i64::from(status))),
("title", Value::Text(error::title(status))),
("message", Value::Text(error::message(status))),
("reference", Value::Text(&reference)),
];
let view = Fields(&fields);
let mut out = Output::new(&mut body, self.shared.page_limit);
if engine.render(t, &view, &mut out).is_ok() {
done = true;
} else {
body.clear();
}
}
if !done {
error::builtin_page(status, &reference, &mut body);
}
reply
.status(status)
.header("content-type", "text/html; charset=utf-8")
.header("x-content-type-options", "nosniff")
.header("cache-control", "no-store")
.body(&body);
self.secure(reply, at.tls, |_| false);
}
fn secure(&self, reply: &mut HttpReply, tls: bool, has: impl Fn(&str) -> bool) {
for (name, value) in self.shared.security.fields(tls) {
if !has(name) {
reply.header(name, value.as_bytes());
}
}
}
pub(crate) fn claim(&self, authority: Option<&str>, path: &str) -> Option<(u8, usize)> {
let lower = authority.unwrap_or("").to_ascii_lowercase();
let lower = lower.trim_end_matches('.');
let host_only = split_port(lower).0.trim_end_matches('.');
let listed = |list: &[Box<str>]| {
list.iter().any(|h| {
let h = h.as_ref();
h == lower || (split_port(h).1.is_none() && h == host_only)
})
};
let rank = if self.site.hosts.is_empty() {
0
} else if listed(&self.site.hosts) || listed(&self.site.aliases) {
2
} else if self.site.development && is_dev_host(host_only) {
1
} else {
return None;
};
let base = self.shared.base.as_ref();
let inside = base.is_empty()
|| path == base
|| path.strip_prefix(base).is_some_and(|r| r.starts_with('/'));
inside.then_some((rank, base.len()))
}
fn tls(&self, env: &Envelope, authority: Option<&str>) -> bool {
if env.client().scheme() != Scheme::Https {
return false;
}
let a = authority.unwrap_or("").to_ascii_lowercase();
!(self.site.development && is_dev_host(split_port(a.trim_end_matches('.')).0))
}
}
impl<G: Gate> HttpService for App<G> {
fn call(&self, request: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
self.respond(request, env, reply)
}
}
impl<G: Gate> fmt::Debug for App<G> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("App")
.field("site", &self.site)
.field("base", &self.shared.base)
.field("routes", &self.entries.len())
.finish_non_exhaustive()
}
}
fn wants_json(accept: Option<&[u8]>) -> bool {
accept.is_some() && parse_net::best_media(accept, &["text/html", "application/json"]) == Some(1)
}
fn compress(c: Compression, accept_encoding: Option<&[u8]>, out: &mut Out) {
if out.status != 200 || out.has("content-encoding") {
return;
}
if !out.get("content-type").is_some_and(compressible) {
return;
}
if !out.has("vary") {
out.header("vary", b"accept-encoding");
}
let accepts = accept_encoding
.and_then(|v| parse_net::best_encoding(Some(v), &["gzip", "identity"]))
== Some(0);
if !accepts || out.body.len() < c.min_bytes {
return;
}
let level = compress_lib::Level::new(c.level).unwrap_or(compress_lib::Level::DEFAULT);
let packed = compress_lib::gzip::compress_with(&out.body, level);
if packed.len() < out.body.len() {
out.body.clear();
out.body.extend_from_slice(&packed);
out.header("content-encoding", b"gzip");
}
}
fn encode_component(s: &str, out: &mut String) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
out.push(b as char);
} else {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 15) as usize] as char);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_port() {
assert_eq!(split_port("a.test:80"), ("a.test", Some("80")));
assert_eq!(split_port("a.test"), ("a.test", None));
assert_eq!(split_port("[::1]:8080"), ("[::1]", Some("8080")));
assert_eq!(split_port("[::1]"), ("[::1]", None));
}
#[test]
fn test_valid_host() {
for ok in ["example.com", "localhost:8080", "[::1]", "127.0.0.1:80"] {
assert!(valid_host(ok), "{ok}");
}
for bad in ["", "a b", "a:", "a:x", "[zz]", "a/b"] {
assert!(!valid_host(bad), "{bad}");
}
}
#[test]
fn test_selector() {
assert!(Routes::prefix("/blog").matches("/blog", None));
assert!(Routes::prefix("/blog/").matches("/blog/:slug", None));
assert!(!Routes::prefix("/blog").matches("/blogger", None));
assert!(Routes::named(&["a"]).matches("/x", Some("a")));
assert!(!Routes::named(&["a"]).matches("/x", None));
}
}