Skip to main content

web_net/
app.rs

1//! The application: its site, its compiled routes, its gate, and the
2//! pipeline every request runs.
3
4use std::borrow::Cow;
5use std::cell::Cell;
6use std::fmt;
7use std::panic::{AssertUnwindSafe, catch_unwind};
8use std::sync::Arc;
9
10use serve_net::http_net::MethodSet;
11use serve_net::{
12    Envelope, HttpHead, HttpReply, HttpRequest, HttpService, Method, Outcome, Scheme, Verdict,
13    WorkClass,
14};
15
16use crate::cx::{Out, is_local_target};
17use crate::error::{self, BuildError, Problem};
18use crate::route::{Access, BoxHandler, Group, Route};
19use crate::router::{self, Captures, Trie};
20use crate::url::{self, TrailingSlash};
21use crate::{
22    Cx, Denial, DenialKind, Event, EventKind, Events, Fields, Gate, NoEvents, Open, Output, Render,
23    Request, RequestId, Result, Stage, TemplateId, Value,
24};
25
26/// The default budget for one rendered page.
27pub const DEFAULT_PAGE_LIMIT: usize = 8 << 20;
28
29/// Statuses with an error template lookup (`error/404`, ...).
30const ERROR_STATUSES: [u16; 14] = [
31    400, 401, 403, 404, 405, 409, 410, 413, 421, 422, 429, 500, 501, 503,
32];
33
34/// A named point in the pipeline where registered code runs, only for the
35/// routes it registered for. A route with no hooks pays nothing.
36#[non_exhaustive]
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum Hook {
39    /// After the access check passed, before the handler: locale, feature
40    /// flags, an A/B variant. An error ends the request.
41    Routed,
42    /// After the handler succeeded, before the reply leaves: add headers.
43    /// An error replaces the reply with the error page.
44    Finish,
45}
46
47/// Which routes a hook runs for.
48#[derive(Clone, Debug)]
49pub struct Routes(Selector);
50
51#[derive(Clone, Debug)]
52enum Selector {
53    All,
54    Prefix(String),
55    Named(Vec<&'static str>),
56}
57
58impl Routes {
59    /// Every route.
60    #[must_use]
61    pub fn all() -> Self {
62        Self(Selector::All)
63    }
64
65    /// Routes whose pattern starts with `prefix` (`/blog`).
66    #[must_use]
67    pub fn prefix(prefix: impl Into<String>) -> Self {
68        Self(Selector::Prefix(prefix.into()))
69    }
70
71    /// Routes with one of these names.
72    #[must_use]
73    pub fn named(names: &[&'static str]) -> Self {
74        Self(Selector::Named(names.to_vec()))
75    }
76
77    fn matches(&self, pattern: &str, name: Option<&str>) -> bool {
78        match &self.0 {
79            Selector::All => true,
80            Selector::Prefix(p) => {
81                let p = p.trim_end_matches('/');
82                pattern == p
83                    || pattern
84                        .strip_prefix(p)
85                        .is_some_and(|rest| rest.starts_with('/'))
86            }
87            Selector::Named(names) => name.is_some_and(|n| names.contains(&n)),
88        }
89    }
90}
91
92type HookFn<I> = Arc<dyn Fn(&mut Cx<'_, I>) -> Result + Send + Sync>;
93
94/// What [`App::classify`] tells a dispatcher before admission.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct Classified {
97    /// The route that will run, if one matched.
98    pub route: Option<RouteId>,
99    /// How its handler behaves (`Quick` when nothing matched).
100    pub work: WorkClass,
101}
102
103/// A declared route (one method of one pattern), numbered in declaration
104/// order.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub struct RouteId(pub u32);
107
108/// The parts of the application every handler can reach.
109pub(crate) struct Shared {
110    pub(crate) render: Option<Box<dyn Render>>,
111    pub(crate) checkpoint: Option<Box<dyn crate::Checkpoint>>,
112    pub(crate) events: Box<dyn Events>,
113    pub(crate) page_limit: usize,
114    pub(crate) base: Box<str>,
115    errors: Vec<(u16, TemplateId)>,
116    sign_in: Option<Box<str>>,
117    challenge: Box<str>,
118    wipe: bool,
119}
120
121#[derive(Clone, Debug, Default)]
122struct Site {
123    /// Lower case; the first is canonical.
124    hosts: Vec<Box<str>>,
125    aliases: Vec<Box<str>>,
126    development: bool,
127    slash: TrailingSlash,
128    lowercase: bool,
129    https_only: bool,
130}
131
132struct Entry<G: Gate> {
133    handler: BoxHandler<G::Identity>,
134    access: Access<G::Rule>,
135    names: Box<[Box<str>]>,
136    pattern: Box<str>,
137    work: WorkClass,
138    template: Option<TemplateId>,
139    routed: Box<[HookFn<G::Identity>]>,
140    finish: Box<[HookFn<G::Identity>]>,
141}
142
143struct Endpoint {
144    /// Entry per method, by `Method::index`.
145    methods: [Option<u32>; 10],
146    allow: Box<str>,
147}
148
149/// A web application: one site, its routes, and its gate. Built once at
150/// start-up with [`App::builder`] or [`App::with_gate`]; immutable after.
151///
152/// It is a `serve_net::HttpService`, so it runs on serve-net as it is, and
153/// it has [`respond`](Self::respond) and
154/// [`respond_identified`](Self::respond_identified) for a wrapper that
155/// calls it from its own service.
156///
157/// ```
158/// use web_net::{App, get};
159///
160/// let app = App::builder()
161///     .route(get("/", |cx| { cx.html(200, "<h1>Hello</h1>"); Ok(()) }).public())
162///     .route(get("/users/:id", |cx| {
163///         let id: u64 = cx.param_as("id")?;
164///         cx.text(200, &format!("user {id}"));
165///         Ok(())
166///     }).public())
167///     .build()?;
168///
169/// let reply = serve_net::testing::respond(&app, b"GET /users/7 HTTP/1.1\r\nHost: a\r\n\r\n");
170/// assert_eq!((reply.status, reply.text()), (200, "user 7"));
171///
172/// let reply = serve_net::testing::respond(&app, b"GET /users/7/ HTTP/1.1\r\nHost: a\r\n\r\n");
173/// assert_eq!(reply.status, 301);
174/// assert_eq!(reply.header("location"), Some(&b"/users/7"[..]));
175/// # Ok::<(), web_net::BuildError>(())
176/// ```
177pub struct App<G: Gate = Open> {
178    gate: G,
179    shared: Shared,
180    site: Site,
181    trie: Trie,
182    endpoints: Vec<Endpoint>,
183    entries: Vec<Entry<G>>,
184    allow_all: Box<str>,
185}
186
187impl App<Open> {
188    /// A builder with the [`Open`] gate: every visitor is a guest and every
189    /// route is declared public.
190    #[must_use]
191    pub fn builder() -> Builder<Open> {
192        Builder::new(Open)
193    }
194}
195
196impl<G: Gate> App<G> {
197    /// A builder with `gate` for authentication and authorization.
198    #[must_use]
199    pub fn with_gate(gate: G) -> Builder<G> {
200        Builder::new(gate)
201    }
202}
203
204/// Declares an application. Every mistake is reported by
205/// [`build`](Self::build) at once, before anything listens.
206pub struct Builder<G: Gate> {
207    gate: G,
208    routes: Vec<Route<G::Identity, G::Rule>>,
209    hooks: Vec<(Hook, Routes, HookFn<G::Identity>)>,
210    site: Site,
211    base: String,
212    render: Option<Box<dyn Render>>,
213    checkpoint: Option<Box<dyn crate::Checkpoint>>,
214    events: Box<dyn Events>,
215    page_limit: usize,
216    sign_in: Option<String>,
217    challenge: String,
218    wipe: bool,
219}
220
221impl<G: Gate> Builder<G> {
222    fn new(gate: G) -> Self {
223        Self {
224            gate,
225            routes: Vec::new(),
226            hooks: Vec::new(),
227            site: Site::default(),
228            base: String::new(),
229            render: None,
230            checkpoint: None,
231            events: Box::new(NoEvents),
232            page_limit: DEFAULT_PAGE_LIMIT,
233            sign_in: None,
234            challenge: "Session".into(),
235            wipe: false,
236        }
237    }
238
239    /// Adds a route.
240    #[must_use]
241    pub fn route(mut self, route: Route<G::Identity, G::Rule>) -> Self {
242        self.routes.push(route);
243        self
244    }
245
246    /// Adds a group's routes.
247    #[must_use]
248    pub fn group(mut self, group: Group<G::Identity, G::Rule>) -> Self {
249        self.routes.extend(group.into_routes());
250        self
251    }
252
253    /// Runs `f` at `hook` for the routes `routes` selects.
254    #[must_use]
255    pub fn hook<F>(mut self, hook: Hook, routes: Routes, f: F) -> Self
256    where
257        F: Fn(&mut Cx<'_, G::Identity>) -> Result + Send + Sync + 'static,
258    {
259        self.hooks.push((hook, routes, Arc::new(f)));
260        self
261    }
262
263    /// A host the site answers (`example.com`, or `localhost:8080` with its
264    /// port). The first one is canonical. With no host declared the site
265    /// answers any host; with hosts declared, any other host gets 421.
266    #[must_use]
267    pub fn host(mut self, host: &str) -> Self {
268        self.site.hosts.push(host.to_ascii_lowercase().into());
269        self
270    }
271
272    /// A host that is redirected (301, or 308 for unsafe methods) to the
273    /// canonical host (`www.example.com` to `example.com`).
274    #[must_use]
275    pub fn alias(mut self, host: &str) -> Self {
276        self.site.aliases.push(host.to_ascii_lowercase().into());
277        self
278    }
279
280    /// A development instance: `localhost`, `127.0.0.1`, `[::1]`, and
281    /// `*.localhost` are answered as the site (with any port), and HTTPS is
282    /// not required for them. Never set this in production: it is the only
283    /// way a `Host: localhost` is trusted.
284    #[must_use]
285    pub fn development(mut self) -> Self {
286        self.site.development = true;
287        self
288    }
289
290    /// The path the site is installed under (`/blog`). It is removed before
291    /// routing and added back by [`Cx::url`], so routes and links are written
292    /// as if the site were at `/`. Requests outside it get 404.
293    #[must_use]
294    pub fn base(mut self, base: &str) -> Self {
295        self.base = base.to_owned();
296        self
297    }
298
299    /// The trailing-slash policy (default [`TrailingSlash::Strip`]).
300    #[must_use]
301    pub fn trailing_slash(mut self, policy: TrailingSlash) -> Self {
302        self.site.slash = policy;
303        self
304    }
305
306    /// Paths are lower case: one with an upper-case letter is redirected.
307    #[must_use]
308    pub fn lowercase_paths(mut self) -> Self {
309        self.site.lowercase = true;
310        self
311    }
312
313    /// Plain HTTP is redirected to HTTPS on the canonical host (behind a
314    /// TLS proxy, serve-net must trust the proxy's scheme). Needs a host.
315    #[must_use]
316    pub fn https_only(mut self) -> Self {
317        self.site.https_only = true;
318        self
319    }
320
321    /// The site-relative sign-in page. A [`Denial::sign_in`] on a `GET` or
322    /// `HEAD` is then answered with a 303 to it, the page asked for passed
323    /// as `next`; without it, or for other methods, the answer is 401.
324    #[must_use]
325    pub fn sign_in(mut self, path: &str) -> Self {
326        self.sign_in = Some(path.to_owned());
327        self
328    }
329
330    /// The `WWW-Authenticate` challenge sent with a 401 (default
331    /// `Session`).
332    #[must_use]
333    pub fn challenge(mut self, challenge: &str) -> Self {
334        challenge.clone_into(&mut self.challenge);
335        self
336    }
337
338    /// The template engine.
339    #[must_use]
340    pub fn render(mut self, engine: impl Render) -> Self {
341        self.render = Some(Box::new(engine));
342        self
343    }
344
345    /// Where refusals, denials, and failures are reported.
346    #[must_use]
347    pub fn events(mut self, events: impl Events) -> Self {
348        self.events = Box::new(events);
349        self
350    }
351
352    /// The in-flight revocation check at web-net's stages.
353    #[must_use]
354    pub fn checkpoint(mut self, checkpoint: impl crate::Checkpoint) -> Self {
355        self.checkpoint = Some(Box::new(checkpoint));
356        self
357    }
358
359    /// The budget for one rendered page (default 8 MiB).
360    #[must_use]
361    pub fn page_limit(mut self, bytes: usize) -> Self {
362        self.page_limit = bytes;
363        self
364    }
365
366    /// Zero the reply buffers web-net reuses after every request, so one
367    /// visitor's page never lingers in memory another request reuses. Costs
368    /// one pass over the bytes written.
369    #[must_use]
370    pub fn wipe_buffers(mut self) -> Self {
371        self.wipe = true;
372        self
373    }
374
375    /// Compiles the application.
376    ///
377    /// # Errors
378    ///
379    /// A [`BuildError`] listing every problem: a route without declared
380    /// access, a malformed pattern, two handlers for one method of one
381    /// path, an unknown template, a malformed host or base path.
382    pub fn build(self) -> std::result::Result<App<G>, BuildError> {
383        let mut problems = Vec::new();
384        let mut problem = |subject: &str, message: String| {
385            problems.push(Problem {
386                subject: subject.to_owned(),
387                message,
388            });
389        };
390
391        // Site.
392        let base = self.base.trim_end_matches('/');
393        if !base.is_empty() {
394            match url::normalize(base, self.site.lowercase) {
395                Ok(n) if n == base && !base.contains(['?', '#']) => {}
396                _ => problem(
397                    "base",
398                    format!("'{}' is not a canonical path such as '/blog'", self.base),
399                ),
400            }
401        }
402        for h in self.site.hosts.iter().chain(&self.site.aliases) {
403            if !valid_host(h) {
404                problem("host", format!("'{h}' is not a host name or address"));
405            }
406        }
407        if self.site.https_only && self.site.hosts.is_empty() {
408            problem(
409                "https_only",
410                "redirecting to HTTPS needs a declared host; add .host(\"example.com\")".into(),
411            );
412        }
413        if let Some(s) = &self.sign_in {
414            if !is_local_target(s) {
415                problem("sign_in", format!("'{s}' is not a site-relative path"));
416            }
417        }
418        if self.challenge.is_empty() || !self.challenge.bytes().all(|b| (0x20..0x7f).contains(&b)) {
419            problem("challenge", "the challenge must be visible ASCII".into());
420        }
421
422        // Error templates.
423        let mut errors = Vec::new();
424        if let Some(r) = &self.render {
425            for s in ERROR_STATUSES {
426                if let Some(t) = r.template(&format!("error/{s}")) {
427                    errors.push((s, t));
428                }
429            }
430        }
431
432        // Routes.
433        let mut trie = Trie::default();
434        let mut endpoints: Vec<Endpoint> = Vec::new();
435        let mut entries: Vec<Entry<G>> = Vec::new();
436        let mut all = MethodSet::EMPTY;
437        for r in self.routes {
438            let parts = match router::parse(&r.pattern) {
439                Ok(p) => p,
440                Err(e) => {
441                    problem(&r.pattern, e.message());
442                    continue;
443                }
444            };
445            if r.handlers.is_empty() {
446                problem(&r.pattern, "the route has no handler".into());
447                continue;
448            }
449            if matches!(r.access, Access::Undeclared) {
450                problem(
451                    &r.pattern,
452                    "the route declares no access; add .public() or .allow(rule)".into(),
453                );
454                continue;
455            }
456            let template = match (r.template, &self.render) {
457                (None, _) => None,
458                (Some(t), None) => {
459                    problem(
460                        &r.pattern,
461                        format!("template '{t}' is declared but no render engine is set"),
462                    );
463                    None
464                }
465                (Some(t), Some(engine)) => {
466                    let id = engine.template(t);
467                    if id.is_none() {
468                        problem(&r.pattern, format!("the engine has no template '{t}'"));
469                    }
470                    id
471                }
472            };
473            let ep = trie.insert(&parts, endpoints.len() as u32) as usize;
474            if ep == endpoints.len() {
475                endpoints.push(Endpoint {
476                    methods: [None; 10],
477                    allow: Box::from(""),
478                });
479            }
480            let names: Box<[Box<str>]> = router::names(&parts).into();
481            for (m, h) in r.handlers {
482                let subject = format!("{} {}", m.as_str().unwrap_or("?"), r.pattern);
483                if m == Method::Other || m == Method::Connect || m == Method::Trace {
484                    problem(&subject, "this method cannot be routed".into());
485                    continue;
486                }
487                let slot = &mut endpoints[ep].methods[m.index() as usize];
488                if slot.is_some() {
489                    problem(
490                        &subject,
491                        "a handler for this method and path exists already".into(),
492                    );
493                    continue;
494                }
495                *slot = Some(entries.len() as u32);
496                let pick = |at: Hook| -> Box<[HookFn<G::Identity>]> {
497                    self.hooks
498                        .iter()
499                        .filter(|(h, sel, _)| *h == at && sel.matches(&r.pattern, r.name))
500                        .map(|(_, _, f)| Arc::clone(f))
501                        .collect()
502                };
503                entries.push(Entry {
504                    handler: h,
505                    access: r.access.clone(),
506                    names: names.clone(),
507                    pattern: r.pattern.as_str().into(),
508                    work: r.work,
509                    template,
510                    routed: pick(Hook::Routed),
511                    finish: pick(Hook::Finish),
512                });
513            }
514        }
515        for e in &mut endpoints {
516            let mut set = MethodSet::EMPTY;
517            for m in [
518                Method::Get,
519                Method::Head,
520                Method::Post,
521                Method::Put,
522                Method::Delete,
523                Method::Options,
524                Method::Patch,
525            ] {
526                if e.methods[m.index() as usize].is_some() {
527                    set = set.with(m);
528                }
529            }
530            if set.contains(Method::Get) {
531                set = set.with(Method::Head);
532            }
533            set = set.with(Method::Options);
534            all = all.union(set);
535            let mut v = Vec::new();
536            set.write_allow(&mut v);
537            e.allow = String::from_utf8(v).unwrap_or_default().into();
538        }
539        if !problems.is_empty() {
540            return Err(BuildError::new(problems));
541        }
542        let mut v = Vec::new();
543        all.with(Method::Options).write_allow(&mut v);
544        Ok(App {
545            gate: self.gate,
546            shared: Shared {
547                render: self.render,
548                checkpoint: self.checkpoint,
549                events: self.events,
550                page_limit: self.page_limit,
551                base: base.into(),
552                errors,
553                sign_in: self.sign_in.map(Into::into),
554                challenge: self.challenge.into(),
555                wipe: self.wipe,
556            },
557            site: self.site,
558            trie,
559            endpoints,
560            entries,
561            allow_all: String::from_utf8(v).unwrap_or_default().into(),
562        })
563    }
564}
565
566impl<G: Gate> fmt::Debug for Builder<G> {
567    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
568        f.debug_struct("Builder")
569            .field("routes", &self.routes)
570            .field("site", &self.site)
571            .field("base", &self.base)
572            .finish_non_exhaustive()
573    }
574}
575
576fn valid_host(h: &str) -> bool {
577    let (host, port) = split_port(h);
578    let host_ok = if let Some(inner) = host.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
579        inner.parse::<std::net::Ipv6Addr>().is_ok()
580    } else {
581        !host.is_empty()
582            && host.len() <= 253
583            && host
584                .bytes()
585                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
586    };
587    host_ok && port.is_none_or(|p| !p.is_empty() && p.parse::<u16>().is_ok())
588}
589
590/// `example.com:8080` to (`example.com`, `Some("8080")`); `[::1]:80` to
591/// (`[::1]`, `Some("80")`).
592fn split_port(a: &str) -> (&str, Option<&str>) {
593    if a.starts_with('[') {
594        return match a.find(']') {
595            Some(i) => match a[i + 1..].strip_prefix(':') {
596                Some(p) => (&a[..=i], Some(p)),
597                None => (a, None),
598            },
599            None => (a, None),
600        };
601    }
602    match a.rsplit_once(':') {
603        Some((h, p)) => (h, Some(p)),
604        None => (a, None),
605    }
606}
607
608fn is_dev_host(host: &str) -> bool {
609    matches!(host, "localhost" | "127.0.0.1" | "[::1]") || host.ends_with(".localhost")
610}
611
612thread_local! {
613    /// Reply buffers reused between requests on one thread. Taken for the
614    /// length of one request and put back, so nothing about a request lives
615    /// here between calls.
616    static SCRATCH: Cell<Option<Box<Out>>> = const { Cell::new(None) };
617}
618
619/// Buffers bigger than this are dropped instead of kept.
620const KEEP_CAPACITY: usize = 1 << 20;
621
622/// Where the site check sent a request.
623enum Placed<'a> {
624    Here(Cow<'a, str>),
625    Redirect(String),
626    Refuse(u16, &'static str, Option<url::UrlError>),
627}
628
629impl<G: Gate> App<G> {
630    /// Runs the whole pipeline for one request, authentication included.
631    pub fn respond(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
632        self.run(req, env, None, reply)
633    }
634
635    /// Runs the pipeline for a request whose caller authenticated it
636    /// already: web-net takes `who` as given and does not call the gate's
637    /// `authenticate`. Authorization still runs for every route that is
638    /// not public.
639    pub fn respond_identified(
640        &self,
641        req: &HttpRequest<'_>,
642        env: &Envelope,
643        who: &G::Identity,
644        reply: &mut HttpReply,
645    ) -> Outcome {
646        self.run(req, env, Some(who), reply)
647    }
648
649    /// Looks at a head before admission: which route would run and how it
650    /// behaves. Nothing is answered and no gate is called.
651    #[must_use]
652    pub fn classify(&self, head: &HttpHead<'_>) -> Classified {
653        let none = Classified {
654            route: None,
655            work: WorkClass::Quick,
656        };
657        let Placed::Here(path) = self.place(head.authority(), head.path(), Scheme::Https, true)
658        else {
659            return none;
660        };
661        let Some((ep, _)) = self.trie.find(&path) else {
662            return none;
663        };
664        match self.entry_for(ep as usize, head.method()) {
665            Some(i) => Classified {
666                route: Some(RouteId(i)),
667                work: self.entries[i as usize].work,
668            },
669            None => none,
670        }
671    }
672
673    /// The gate.
674    #[must_use]
675    pub fn gate(&self) -> &G {
676        &self.gate
677    }
678
679    fn entry_for(&self, ep: usize, m: Method) -> Option<u32> {
680        let methods = &self.endpoints[ep].methods;
681        match m {
682            Method::Other => None,
683            Method::Head => {
684                methods[Method::Head.index() as usize].or(methods[Method::Get.index() as usize])
685            }
686            m => methods[m.index() as usize],
687        }
688    }
689
690    /// Steps 1 and 2: which site, which canonical path. `quiet` (for
691    /// `classify`) skips building a redirect.
692    fn place<'a>(
693        &self,
694        authority: Option<&'a str>,
695        raw: &'a str,
696        scheme: Scheme,
697        quiet: bool,
698    ) -> Placed<'a> {
699        let site = &self.site;
700        let authority = authority.unwrap_or("");
701        let lower = authority.to_ascii_lowercase();
702        let lower = lower.trim_end_matches('.');
703        let host_only = split_port(lower).0.trim_end_matches('.');
704        let listed = |list: &[Box<str>]| {
705            list.iter().any(|h| {
706                let h = h.as_ref();
707                h == lower || (split_port(h).1.is_none() && h == host_only)
708            })
709        };
710        let dev = site.development && is_dev_host(host_only);
711        let mut target_host: Option<&str> = None;
712        if !site.hosts.is_empty() && !dev && !authority.is_empty() && !listed(&site.hosts) {
713            if listed(&site.aliases) {
714                target_host = site.hosts.first().map(AsRef::as_ref);
715            } else {
716                return Placed::Refuse(421, "host.unknown", None);
717            }
718        }
719        let mut https = scheme == Scheme::Https;
720        if site.https_only && !https && !dev {
721            https = true;
722            target_host = target_host.or(site.hosts.first().map(AsRef::as_ref));
723        }
724
725        // The path: canonical, inside the base, with the slash policy.
726        let canon = match url::normalize(raw, site.lowercase) {
727            Ok(c) => c,
728            Err(e) => return Placed::Refuse(400, e.code(), Some(e)),
729        };
730        let base = self.shared.base.as_ref();
731        let c: &str = &canon;
732        let rel_start = if base.is_empty() {
733            0
734        } else if c == base {
735            c.len()
736        } else if c.strip_prefix(base).is_some_and(|r| r.starts_with('/')) {
737            base.len()
738        } else {
739            return Placed::Refuse(404, "site.outside_base", None);
740        };
741        let rel = &c[rel_start..];
742        let mut fixed: Option<String> = None;
743        if rel.is_empty() {
744            fixed = Some("/".into());
745        } else if rel.len() > 1 {
746            match site.slash {
747                TrailingSlash::Strip => {
748                    if let Some(s) = rel.strip_suffix('/') {
749                        fixed = Some(s.to_owned());
750                    }
751                }
752                TrailingSlash::Add => {
753                    let last = rel.rsplit('/').next().unwrap_or("");
754                    if !rel.ends_with('/') && !last.contains('.') {
755                        fixed = Some(format!("{rel}/"));
756                    }
757                }
758                TrailingSlash::Ignore => {}
759            }
760        }
761        if target_host.is_none() && fixed.is_none() {
762            if let Cow::Borrowed(c) = canon {
763                let mut path = &c[rel_start..];
764                if site.slash == TrailingSlash::Ignore && path.len() > 1 {
765                    path = path.strip_suffix('/').unwrap_or(path);
766                }
767                return Placed::Here(Cow::Borrowed(path));
768            }
769        }
770        if quiet {
771            return Placed::Refuse(0, "", None);
772        }
773        let rel_out = fixed.as_deref().unwrap_or(rel);
774        let mut loc = String::with_capacity(raw.len() + 32);
775        if let Some(h) = target_host {
776            loc.push_str(if https { "https://" } else { "http://" });
777            loc.push_str(h);
778        }
779        loc.push_str(base);
780        loc.push_str(rel_out);
781        Placed::Redirect(loc)
782    }
783
784    fn run(
785        &self,
786        http: &HttpRequest<'_>,
787        env: &Envelope,
788        who: Option<&G::Identity>,
789        reply: &mut HttpReply,
790    ) -> Outcome {
791        let method = http.method();
792        let raw_path = http.path();
793        if raw_path == "*" {
794            if method == Method::Options {
795                reply.status(204).header("allow", self.allow_all.as_bytes());
796            } else {
797                self.refuse(
798                    400,
799                    "url.asterisk",
800                    EventKind::Refused,
801                    None,
802                    None,
803                    env,
804                    reply,
805                );
806            }
807            return Outcome::Done;
808        }
809        let authority = http.head().authority();
810        let host_raw = authority.unwrap_or("");
811        let placed = self.place(authority, raw_path, env.client().scheme(), false);
812        let path = match placed {
813            Placed::Here(path) => path,
814            Placed::Redirect(mut loc) => {
815                if let Some(q) = http.query() {
816                    loc.push('?');
817                    loc.push_str(q);
818                }
819                let status = if matches!(method, Method::Get | Method::Head) {
820                    301
821                } else {
822                    308
823                };
824                reply.status(status).header("location", loc.as_bytes());
825                return Outcome::Done;
826            }
827            Placed::Refuse(status, code, src) => {
828                let src = src
829                    .as_ref()
830                    .map(|e| e as &(dyn std::error::Error + 'static));
831                self.refuse(status, code, EventKind::Refused, None, src, env, reply);
832                return Outcome::Done;
833            }
834        };
835        let mut req = Request {
836            http,
837            env,
838            path: &path,
839            host: host_raw,
840            route: None,
841        };
842
843        // Authentication, unless the caller did it.
844        let owned;
845        let who: &G::Identity = match who {
846            Some(w) => w,
847            None => match self.gate.authenticate(&req) {
848                Ok(w) => {
849                    owned = w;
850                    &owned
851                }
852                Err(d) => {
853                    self.deny(d, &req, reply);
854                    return Outcome::Done;
855                }
856            },
857        };
858
859        // Route and method.
860        let Some((ep, caps)) = self.trie.find(&path) else {
861            self.refuse(
862                404,
863                "route.none",
864                EventKind::Refused,
865                None,
866                None,
867                env,
868                reply,
869            );
870            return Outcome::Done;
871        };
872        let endpoint = &self.endpoints[ep as usize];
873        let Some(idx) = self.entry_for(ep as usize, method) else {
874            if method == Method::Options {
875                reply.status(204).header("allow", endpoint.allow.as_bytes());
876                return Outcome::Done;
877            }
878            let status = if method == Method::Other { 501 } else { 405 };
879            let code = if status == 501 {
880                "method.unknown"
881            } else {
882                "method.not_allowed"
883            };
884            self.refuse(status, code, EventKind::Refused, None, None, env, reply);
885            if status == 405 {
886                reply.header("allow", endpoint.allow.as_bytes());
887            }
888            return Outcome::Done;
889        };
890        let entry = &self.entries[idx as usize];
891        req.route = Some(&entry.pattern);
892
893        // Authorization.
894        if let Access::Rule(rule) = &entry.access {
895            if let Err(d) = self.gate.authorize(who, rule, &req) {
896                self.deny(d, &req, reply);
897                return Outcome::Done;
898            }
899        }
900        if let Some(cp) = &self.shared.checkpoint {
901            match cp.check(env, Stage::Authorized) {
902                Verdict::Pass => {}
903                Verdict::Deny { status } => {
904                    self.refuse(
905                        status,
906                        "checkpoint.denied",
907                        EventKind::Revoked,
908                        req.route,
909                        None,
910                        env,
911                        reply,
912                    );
913                    return Outcome::Done;
914                }
915                _ => {
916                    self.refuse(
917                        403,
918                        "checkpoint.killed",
919                        EventKind::Revoked,
920                        req.route,
921                        None,
922                        env,
923                        reply,
924                    );
925                    return Outcome::Close;
926                }
927            }
928        }
929
930        // The handler, with its hooks, on a buffer web-net holds.
931        let mut out = SCRATCH.with(Cell::take).unwrap_or_default();
932        out.reset();
933        let outcome = self.handle(entry, req, who, caps, &mut out, reply);
934        if self.shared.wipe {
935            out.wipe();
936        }
937        if out.body.capacity() <= KEEP_CAPACITY && out.values.capacity() <= KEEP_CAPACITY {
938            SCRATCH.with(|s| s.set(Some(out)));
939        }
940        outcome
941    }
942
943    fn handle(
944        &self,
945        entry: &Entry<G>,
946        req: Request<'_>,
947        who: &G::Identity,
948        caps: Captures,
949        out: &mut Out,
950        reply: &mut HttpReply,
951    ) -> Outcome {
952        let env = req.env;
953        let route = req.route;
954        let result = {
955            let mut cx = Cx {
956                req,
957                who,
958                caps,
959                names: &entry.names,
960                out: &mut *out,
961                shared: &self.shared,
962                template: entry.template,
963            };
964            catch_unwind(AssertUnwindSafe(|| -> Result {
965                for h in entry.routed.iter() {
966                    h(&mut cx)?;
967                }
968                entry.handler.call(&mut cx)?;
969                for h in entry.finish.iter() {
970                    h(&mut cx)?;
971                }
972                Ok(())
973            }))
974        };
975        match result {
976            Ok(Ok(())) if !out.invalid => {
977                reply.status(out.status);
978                for &(ns, ne, vs, ve) in &out.spans {
979                    let name = core::str::from_utf8(&out.names[ns as usize..ne as usize])
980                        .unwrap_or("x-invalid");
981                    reply.header(name, &out.values[vs as usize..ve as usize]);
982                }
983                if !out.has("x-content-type-options") {
984                    reply.header("x-content-type-options", "nosniff");
985                }
986                reply.body(&out.body);
987                Outcome::Done
988            }
989            Ok(Ok(())) => {
990                out.reset();
991                self.refuse(
992                    500,
993                    "reply.invalid_field",
994                    EventKind::Failed,
995                    route,
996                    None,
997                    env,
998                    reply,
999                );
1000                Outcome::Done
1001            }
1002            Ok(Err(e)) => {
1003                out.reset();
1004                let src = std::error::Error::source(&e);
1005                self.refuse(
1006                    e.status(),
1007                    e.code(),
1008                    EventKind::Failed,
1009                    route,
1010                    src,
1011                    env,
1012                    reply,
1013                );
1014                Outcome::Done
1015            }
1016            Err(_) => {
1017                out.reset();
1018                self.refuse(
1019                    500,
1020                    "handler.panic",
1021                    EventKind::Panicked,
1022                    route,
1023                    None,
1024                    env,
1025                    reply,
1026                );
1027                Outcome::Done
1028            }
1029        }
1030    }
1031
1032    /// A gate's denial as a response.
1033    fn deny(&self, d: Denial, req: &Request<'_>, reply: &mut HttpReply) {
1034        let env = req.env;
1035        if d.kind() == DenialKind::SignIn {
1036            if let Some(page) = &self.shared.sign_in {
1037                if matches!(req.method(), Method::Get | Method::Head) {
1038                    let mut loc = String::with_capacity(64);
1039                    loc.push_str(&self.shared.base);
1040                    loc.push_str(page);
1041                    loc.push_str(if page.contains('?') {
1042                        "&next="
1043                    } else {
1044                        "?next="
1045                    });
1046                    let mut next = String::new();
1047                    next.push_str(&self.shared.base);
1048                    next.push_str(req.path);
1049                    if let Some(q) = req.query() {
1050                        next.push('?');
1051                        next.push_str(q);
1052                    }
1053                    encode_component(&next, &mut loc);
1054                    self.event(EventKind::Denied, env, req.route, 303, d.code(), None);
1055                    reply.status(303).header("location", loc.as_bytes());
1056                    return;
1057                }
1058            }
1059        }
1060        let status = d.status_code();
1061        self.refuse(
1062            status,
1063            d.code(),
1064            EventKind::Denied,
1065            req.route,
1066            None,
1067            env,
1068            reply,
1069        );
1070        if status == 401 {
1071            reply.header("www-authenticate", self.shared.challenge.as_bytes());
1072        }
1073    }
1074
1075    fn event(
1076        &self,
1077        kind: EventKind,
1078        env: &Envelope,
1079        route: Option<&str>,
1080        status: u16,
1081        code: &'static str,
1082        source: Option<&(dyn std::error::Error + 'static)>,
1083    ) {
1084        self.shared.events.event(&Event {
1085            kind,
1086            id: RequestId::of(env),
1087            route,
1088            status,
1089            code,
1090            source,
1091        });
1092    }
1093
1094    /// The one function every refusal and error goes through: the status,
1095    /// the error template for it or the built-in page, the reference; the
1096    /// code and source to the events only.
1097    #[allow(clippy::too_many_arguments)]
1098    fn refuse(
1099        &self,
1100        status: u16,
1101        code: &'static str,
1102        kind: EventKind,
1103        route: Option<&str>,
1104        source: Option<&(dyn std::error::Error + 'static)>,
1105        env: &Envelope,
1106        reply: &mut HttpReply,
1107    ) {
1108        self.event(kind, env, route, status, code, source);
1109        let reference = RequestId::of(env).to_string();
1110        let mut body = Vec::with_capacity(512);
1111        let mut done = false;
1112        if let (Some(engine), Some(&(_, t))) = (
1113            self.shared.render.as_deref(),
1114            self.shared.errors.iter().find(|(s, _)| *s == status),
1115        ) {
1116            let fields = [
1117                ("status", Value::Int(i64::from(status))),
1118                ("title", Value::Text(error::title(status))),
1119                ("message", Value::Text(error::message(status))),
1120                ("reference", Value::Text(&reference)),
1121            ];
1122            let view = Fields(&fields);
1123            let mut out = Output::new(&mut body, self.shared.page_limit);
1124            if engine.render(t, &view, &mut out).is_ok() {
1125                done = true;
1126            } else {
1127                body.clear();
1128            }
1129        }
1130        if !done {
1131            error::builtin_page(status, &reference, &mut body);
1132        }
1133        reply
1134            .status(status)
1135            .header("content-type", "text/html; charset=utf-8")
1136            .header("x-content-type-options", "nosniff")
1137            .header("cache-control", "no-store")
1138            .body(&body);
1139    }
1140}
1141
1142impl<G: Gate> HttpService for App<G> {
1143    fn call(&self, request: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
1144        self.respond(request, env, reply)
1145    }
1146}
1147
1148impl<G: Gate> fmt::Debug for App<G> {
1149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1150        f.debug_struct("App")
1151            .field("site", &self.site)
1152            .field("base", &self.shared.base)
1153            .field("routes", &self.entries.len())
1154            .finish_non_exhaustive()
1155    }
1156}
1157
1158/// Percent-encodes a query component: everything but unreserved and `/`.
1159fn encode_component(s: &str, out: &mut String) {
1160    const HEX: &[u8; 16] = b"0123456789ABCDEF";
1161    for b in s.bytes() {
1162        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/') {
1163            out.push(b as char);
1164        } else {
1165            out.push('%');
1166            out.push(HEX[(b >> 4) as usize] as char);
1167            out.push(HEX[(b & 15) as usize] as char);
1168        }
1169    }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn test_split_port() {
1178        assert_eq!(split_port("a.test:80"), ("a.test", Some("80")));
1179        assert_eq!(split_port("a.test"), ("a.test", None));
1180        assert_eq!(split_port("[::1]:8080"), ("[::1]", Some("8080")));
1181        assert_eq!(split_port("[::1]"), ("[::1]", None));
1182    }
1183
1184    #[test]
1185    fn test_valid_host() {
1186        for ok in ["example.com", "localhost:8080", "[::1]", "127.0.0.1:80"] {
1187            assert!(valid_host(ok), "{ok}");
1188        }
1189        for bad in ["", "a b", "a:", "a:x", "[zz]", "a/b"] {
1190            assert!(!valid_host(bad), "{bad}");
1191        }
1192    }
1193
1194    #[test]
1195    fn test_selector() {
1196        assert!(Routes::prefix("/blog").matches("/blog", None));
1197        assert!(Routes::prefix("/blog/").matches("/blog/:slug", None));
1198        assert!(!Routes::prefix("/blog").matches("/blogger", None));
1199        assert!(Routes::named(&["a"]).matches("/x", Some("a")));
1200        assert!(!Routes::named(&["a"]).matches("/x", None));
1201    }
1202}