Skip to main content

decdn_cli/commands/
gateway.rs

1//! `decdn gateway`: serve `decdn:` URIs (#2449) to a browser over loopback
2//! HTTP.
3//!
4//! **Content.** `GET /<hash>[/<path>][?ns=<id>]` serves
5//! `decdn:<hash>[/<path>][?ns=<id>]`. A blob or a bundle entry streams its
6//! verified bytes in order as they arrive; a whole bundle (`/<hash>/`) gets an
7//! HTML index of its entries. A content request carries [`TOKEN_HEADER`] (the
8//! gateway token) and [`HOST_HEADER`] (the host of the page it serves); the
9//! browser extension adds both, so page scripts never see the token. The
10//! gateway pays with the registered capability whose scope covers that host:
11//! an exact host first, then `*.<domain>`, then `*`; between equal scopes, the
12//! token holder's own registration before a page's, then the earlier
13//! registration. A capability whose pull stops for funding before its first
14//! byte is retired and the next covering one takes the request. `HEAD` gets
15//! 405, since answering it would cost a paid pull.
16//!
17//! **Capabilities.** The gateway holds one delegate key per capability, in
18//! memory only. `GET /_delegate` mints a fresh key and returns its address, so
19//! no two callers see the same address and none can follow the user across
20//! sites; a key no capability claims within [`PENDING_TTL`] is dropped. A
21//! caller is a web page by its `http(s)` `Origin`, or the gateway token holder
22//! from outside any page. The pages of one site (a registrable domain by the
23//! Public Suffix List, so subdomains share it) hold at most a few keys,
24//! pending or registered, within a bound shared by all pages; the token
25//! holder mints outside both bounds.
26//! `PUT /_capability` registers a `dcap1:` capability issued to a minted
27//! address, with a scope. The capability's owner signature must recover the
28//! on-chain owner of its pool on the gateway's deployment. A page registers
29//! through CORS, and its scope must cover its own origin's host, whether or
30//! not the request also carries the token: `example.com` may register
31//! `example.com`, `*.example.com` or `*`, never another host, and never a
32//! wildcard over a public suffix such as `*.com` or `*.github.io`. The token
33//! holder registers any scope. `GET /_capability` lists the registrations to
34//! the token holder. A restart drops every key, and with it every capability
35//! issued to one.
36//!
37//! **Boundaries.** Only `/_delegate` and `/_capability` answer cross-origin;
38//! content never does, so a page can neither read a response nor set the
39//! token or host headers itself. The token lives in `<data-dir>/token`,
40//! owner-only. The gateway listens on loopback only and checks `Host` against
41//! DNS rebinding.
42//!
43//! **Pulls.** Every pull runs the blob fetch of `decdn get` with its bytes
44//! going to the HTTP response, on one long-lived worker thread: the endpoint,
45//! the buyer-pool store and the connections to nodes are built once per
46//! gateway run, and up to 16 pulls stream at once, so a response whose reader
47//! stops holds only its own pull.
48
49use std::collections::HashMap;
50use std::convert::Infallible;
51use std::fmt::Write as _;
52use std::net::SocketAddr;
53use std::path::{Path, PathBuf};
54use std::sync::Arc;
55use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
56
57use alloy::primitives::Address;
58use alloy::signers::local::PrivateKeySigner;
59use alloy::sol_types::Eip712Domain;
60use anyhow::Context as _;
61use bytes::Bytes;
62use decdn_client::bundle::{BundleManifest, parse_hash};
63use decdn_client::session::ChainConfig;
64use decdn_client::uri::{DecdnUri, UriTarget};
65use decdn_common::cli::{self, ClientFetchArgs, GatewayArgs};
66use decdn_common::config::load_file_config;
67use decdn_common::redact::{sanitize_err_chain, sanitize_error_sources};
68use decdn_incentive::payment_pool::PaymentPool;
69use decdn_incentive::{CapabilityGrant, PoolId, voucher_domain};
70use http_body_util::combinators::BoxBody;
71use http_body_util::{BodyExt as _, Full, Limited, StreamBody};
72use hyper::body::{Body as HttpBody, Frame};
73use hyper::service::service_fn;
74use hyper::{HeaderMap, Method, Request, Response, StatusCode, header};
75use hyper_util::rt::TokioIo;
76use tokio::io::{AsyncReadExt as _, DuplexStream};
77use tokio::net::TcpListener;
78use tokio::sync::{Mutex, oneshot};
79
80use super::fetch::{BlobFetch, Credential, MAX_MANIFEST_BYTES, manifest_cap_mb, resolve_chain};
81use crate::known_chains;
82
83mod worker;
84
85use worker::{FetchBackend, Job, PullWorker};
86
87/// The request header carrying the gateway token.
88pub const TOKEN_HEADER: &str = "x-decdn-token";
89
90/// The request header naming the host of the page a content request serves.
91pub const HOST_HEADER: &str = "x-decdn-host";
92
93/// How long a minted delegate waits for a capability before it is dropped.
94pub const PENDING_TTL: Duration = Duration::from_mins(15);
95
96/// The most live delegates, pending or registered, the pages of one site
97/// hold, so no site can use up another's room or stock capabilities without
98/// bound, however many subdomains it serves pages from.
99const MAX_DELEGATES_PER_SITE: usize = 8;
100
101/// The most live delegates all pages hold together: the gateway holds page
102/// keys within a fixed bound.
103const MAX_PAGE_DELEGATES: usize = 1024;
104
105/// The largest `PUT /_capability` body.
106const MAX_BODY_BYTES: usize = 64 * 1024;
107
108/// A gateway pull that makes no verified progress for this long gives up, so
109/// a stuck request does not hold its pull slot.
110const GIVE_UP_AFTER_SECS: u64 = 120;
111
112/// The most pulls that run at once; a request past it waits for a slot. Each
113/// pull stripes across up to `--max-sources` holders, so this also bounds the
114/// streams the gateway opens on one node's connection.
115const MAX_PULLS: usize = 16;
116
117/// The most parsed manifests the gateway keeps.
118const MAX_CACHED_MANIFESTS: usize = 16;
119
120/// The manifest bytes the cache keeps across all its entries. The newest
121/// entry stays even when it alone exceeds this.
122const MANIFEST_CACHE_BYTES: usize = 64 << 20;
123
124/// The duplex buffer between a pull and its HTTP response.
125const PIPE_BYTES: usize = 1 << 20;
126
127/// The read size for response chunks.
128const CHUNK_BYTES: usize = 256 * 1024;
129
130type Body = BoxBody<Bytes, std::io::Error>;
131
132/// Run the gateway until Ctrl-C.
133///
134/// # Errors
135///
136/// When `--listen` is not a loopback address, when keystore or capability
137/// flags are given, when the data dir, token or listener cannot be set up, or
138/// when the chain coordinates cannot be resolved.
139pub async fn gateway(args: &GatewayArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
140    anyhow::ensure!(
141        args.listen.ip().is_loopback(),
142        "--listen must be a loopback address"
143    );
144    let mut common = keyless(&args.common)?;
145    fill_chain_defaults(&mut common, config_path)?;
146    let chain = resolve_chain(&common, &load_file_config(config_path)?)?.config;
147    common.give_up_after_secs = common.give_up_after_secs.or(Some(GIVE_UP_AFTER_SECS));
148    let root = common
149        .data_dir
150        .clone()
151        .unwrap_or_else(|| cli::common::expand_tilde(Path::new("~/.decdn/gateway")));
152    private_dir(&root)?;
153    let token = load_or_create_token(&root.join("token"))?;
154    // The keys die with the process, so their lane records do too: the buyer
155    // store lives in a directory removed when the gateway stops.
156    let store_dir = tempfile::Builder::new()
157        .prefix("store-")
158        .tempdir_in(&root)
159        .context("create the gateway's buyer store dir")?;
160    private_dir(store_dir.path())?;
161    common.data_dir = Some(store_dir.path().to_path_buf());
162
163    let listener = TcpListener::bind(args.listen)
164        .await
165        .with_context(|| format!("listen on {}", args.listen))?;
166    let pulls = PullWorker::start(
167        FetchBackend {
168            common: common.clone(),
169            config_path: config_path.map(Path::to_path_buf),
170        },
171        MAX_PULLS,
172    )
173    .await
174    .context("start the gateway's pull worker")?;
175    let gateway = Arc::new(Gateway {
176        common,
177        token,
178        domain: voucher_domain(chain.chain_id, chain.payment_pool),
179        chain,
180        delegates: Mutex::new(HashMap::new()),
181        pulls,
182        manifests: std::sync::Mutex::new(ManifestCache::default()),
183        manifest_fill: Mutex::new(()),
184        hosts: allowed_hosts(args.listen),
185    });
186
187    println!(
188        "decdn gateway on http://{}/ (Ctrl-C to stop)\ngateway token: {} (also in {})",
189        args.listen,
190        gateway.token,
191        root.join("token").display()
192    );
193    let served = serve(&gateway, &listener).await;
194    // The worker's queued lane writes land before the store dir is removed.
195    gateway.pulls.stop().await;
196    served
197}
198
199/// Accept connections on `listener` until Ctrl-C.
200async fn serve(gateway: &Arc<Gateway>, listener: &TcpListener) -> anyhow::Result<()> {
201    loop {
202        let (stream, _) = tokio::select! {
203            accepted = listener.accept() => accepted.context("accept a connection")?,
204            _ = tokio::signal::ctrl_c() => return Ok(()),
205        };
206        let gateway = Arc::clone(gateway);
207        tokio::spawn(async move {
208            let service = service_fn(move |req| {
209                let gateway = Arc::clone(&gateway);
210                async move { Ok::<_, Infallible>(gateway.handle(req).await) }
211            });
212            if let Err(e) = hyper::server::conn::http1::Builder::new()
213                .timer(hyper_util::rt::TokioTimer::new())
214                .header_read_timeout(Duration::from_secs(10))
215                .serve_connection(TokioIo::new(stream), service)
216                .await
217            {
218                tracing::debug!(error = %e, "gateway connection ended");
219            }
220        });
221    }
222}
223
224/// The fetch flags the gateway pulls with. It holds its own delegate keys, so
225/// key and capability flags are refused, and a keystore password file (often
226/// `DECDN_KEYSTORE_PASSWORD_FILE` in the environment) is dropped unread.
227fn keyless(common: &ClientFetchArgs) -> anyhow::Result<ClientFetchArgs> {
228    anyhow::ensure!(
229        common.keystore.is_none()
230            && common.capability.is_none()
231            && common.capability_file.is_none(),
232        "the gateway holds its own delegate keys: register capabilities through \
233         `PUT /_capability` instead of --keystore / --capability / --capability-file"
234    );
235    let mut common = common.clone();
236    common.keystore_password_file = None;
237    Ok(common)
238}
239
240/// Chain coordinates neither a flag nor the config file sets come from the
241/// built-in testnet, so `decdn gateway` runs with no config.
242fn fill_chain_defaults(
243    common: &mut ClientFetchArgs,
244    config_path: Option<&Path>,
245) -> anyhow::Result<()> {
246    let file = load_file_config(config_path)?;
247    let bc = file.blockchain.as_ref();
248    let Some(chain) = known_chains::resolve(None)? else {
249        return Ok(());
250    };
251    let addresses = chain.addresses()?;
252    if common.rpc_url.is_none() && bc.and_then(|b| b.rpc_url.as_ref()).is_none() {
253        common.rpc_url = Some(chain.public_rpc.to_owned());
254    }
255    if common.chain_id.is_none() && bc.and_then(|b| b.chain_id).is_none() {
256        common.chain_id = Some(chain.chain_id);
257    }
258    if common.payment_pool_address.is_none()
259        && bc.and_then(|b| b.payment_pool_address.as_ref()).is_none()
260    {
261        common.payment_pool_address = Some(addresses.payment_pool);
262    }
263    if common.slash_judge_address.is_none()
264        && bc.and_then(|b| b.slash_judge_address.as_ref()).is_none()
265    {
266        common.slash_judge_address = Some(addresses.slash_judge);
267    }
268    if common.capacity_bond_address.is_none()
269        && bc.and_then(|b| b.capacity_bond_address.as_ref()).is_none()
270    {
271        common.capacity_bond_address = Some(addresses.capacity_bond);
272    }
273    Ok(())
274}
275
276/// The `Host` header values the gateway answers: its own loopback address
277/// under each name a browser may use. Anything else is a DNS-rebinding attempt.
278fn allowed_hosts(listen: SocketAddr) -> Vec<String> {
279    let port = listen.port();
280    vec![
281        listen.to_string(),
282        format!("localhost:{port}"),
283        format!("127.0.0.1:{port}"),
284        format!("[::1]:{port}"),
285    ]
286}
287
288/// Create `dir` owner-only.
289fn private_dir(dir: &Path) -> anyhow::Result<()> {
290    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
291    #[cfg(unix)]
292    {
293        use std::os::unix::fs::PermissionsExt as _;
294        std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
295            .with_context(|| format!("restrict {}", dir.display()))?;
296    }
297    Ok(())
298}
299
300/// The gateway token in `path`, or a fresh random one written there
301/// owner-only.
302fn load_or_create_token(path: &Path) -> anyhow::Result<String> {
303    if let Ok(token) = std::fs::read_to_string(path) {
304        let token = token.trim().to_owned();
305        anyhow::ensure!(
306            token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()),
307            "{} is not a gateway token; delete it to make a new one",
308            path.display()
309        );
310        return Ok(token);
311    }
312    let token = alloy::hex::encode(rand::random::<[u8; 32]>());
313    std::fs::write(path, &token).with_context(|| format!("write {}", path.display()))?;
314    #[cfg(unix)]
315    {
316        use std::os::unix::fs::PermissionsExt as _;
317        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
318            .with_context(|| format!("restrict {}", path.display()))?;
319    }
320    Ok(token)
321}
322
323/// Compare `given` with the token without leaking where they differ.
324fn token_matches(token: &str, given: &str) -> bool {
325    token.len() == given.len()
326        && token
327            .bytes()
328            .zip(given.bytes())
329            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
330            == 0
331}
332
333fn now_secs() -> u64 {
334    SystemTime::now()
335        .duration_since(UNIX_EPOCH)
336        .map_or(0, |d| d.as_secs())
337}
338
339/// The hosts a capability pays for.
340#[derive(Debug, Clone, PartialEq, Eq)]
341enum Scope {
342    /// Every host (`*`).
343    Any,
344    /// A domain and its subdomains (`*.example.com`).
345    Domain(String),
346    /// One host (`example.com`).
347    Host(String),
348}
349
350impl Scope {
351    fn parse(s: &str) -> Result<Self, String> {
352        let s = s.trim().to_ascii_lowercase();
353        if s == "*" {
354            return Ok(Self::Any);
355        }
356        if let Some(domain) = s.strip_prefix("*.") {
357            return valid_host(domain).map(|()| Self::Domain(domain.to_owned()));
358        }
359        valid_host(&s).map(|()| Self::Host(s))
360    }
361
362    fn covers(&self, host: &str) -> bool {
363        match self {
364            Self::Any => true,
365            Self::Domain(domain) => {
366                host == domain
367                    || host
368                        .strip_suffix(domain.as_str())
369                        .is_some_and(|prefix| prefix.ends_with('.'))
370            }
371            Self::Host(h) => host == h,
372        }
373    }
374
375    /// More specific scopes win when several cover a host.
376    const fn rank(&self) -> u8 {
377        match self {
378            Self::Host(_) => 2,
379            Self::Domain(_) => 1,
380            Self::Any => 0,
381        }
382    }
383}
384
385impl std::fmt::Display for Scope {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        match self {
388            Self::Any => f.write_str("*"),
389            Self::Domain(d) => write!(f, "*.{d}"),
390            Self::Host(h) => f.write_str(h),
391        }
392    }
393}
394
395fn valid_host(host: &str) -> Result<(), String> {
396    let ok = !host.is_empty()
397        && host.len() <= 253
398        && host.split('.').all(|label| {
399            !label.is_empty()
400                && label.len() <= 63
401                && label
402                    .bytes()
403                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
404        });
405    if ok {
406        Ok(())
407    } else {
408        Err(format!("`{host}` is not a host name"))
409    }
410}
411
412/// The host of a web page's `Origin` header (`https://example.com:8443` gives
413/// `example.com`). `None` for an opaque origin and for every scheme other
414/// than `http` and `https`, such as an extension's.
415fn origin_host(origin: &str) -> Option<String> {
416    let rest = origin
417        .strip_prefix("https://")
418        .or_else(|| origin.strip_prefix("http://"))?;
419    let host = rest.split(['/', ':']).next()?;
420    let host = host.to_ascii_lowercase();
421    valid_host(&host).ok().map(|()| host)
422}
423
424/// Who calls `/_delegate` or `/_capability`.
425#[derive(Debug, PartialEq, Eq)]
426enum Caller {
427    /// A web page, by its `Origin` and that origin's host. A page stays a
428    /// page when its request also carries the token.
429    Page { origin: String, host: String },
430    /// The token holder, from outside any web page.
431    Token,
432}
433
434impl Caller {
435    fn of(origin: Option<&str>, authorized: bool) -> Result<Self, &'static str> {
436        match origin {
437            Some(origin) => {
438                if let Some(host) = origin_host(origin) {
439                    Ok(Self::Page {
440                        origin: origin.to_owned(),
441                        host,
442                    })
443                } else if origin == "null" {
444                    Err("an opaque origin may not call the gateway\n")
445                } else if authorized {
446                    Ok(Self::Token)
447                } else {
448                    Err("token required\n")
449                }
450            }
451            None if authorized => Ok(Self::Token),
452            None => Err("a page calls with its Origin; use the gateway token otherwise\n"),
453        }
454    }
455
456    /// The page origin, or `None` for the token holder.
457    fn origin(&self) -> Option<&str> {
458        match self {
459            Self::Page { origin, .. } => Some(origin),
460            Self::Token => None,
461        }
462    }
463
464    /// The page's site, or `None` for the token holder.
465    fn site(&self) -> Option<String> {
466        match self {
467            Self::Page { host, .. } => Some(site(host)),
468            Self::Token => None,
469        }
470    }
471}
472
473/// The site `host` belongs to: its registrable domain by the Public Suffix
474/// List (`a.b.example.co.uk` gives `example.co.uk`), or the host itself for an
475/// IP address and a name with no registrable domain, such as `localhost`.
476fn site(host: &str) -> String {
477    if host.parse::<std::net::IpAddr>().is_ok() {
478        return host.to_owned();
479    }
480    psl::domain_str(host).unwrap_or(host).to_owned()
481}
482
483/// Whether `caller` may register `scope`: a page only a scope that covers its
484/// own host and is no wildcard over a public suffix (`*.io`, `*.github.io`),
485/// the token holder any scope.
486fn may_register(caller: &Caller, scope: &Scope) -> Result<(), String> {
487    let Caller::Page { host, .. } = caller else {
488        return Ok(());
489    };
490    if !scope.covers(host) {
491        return Err(format!(
492            "{host} may not register scope `{scope}`: it does not cover {host}"
493        ));
494    }
495    if let Scope::Domain(domain) = scope
496        && psl::suffix_str(domain) == Some(domain.as_str())
497    {
498        return Err(format!(
499            "{host} may not register scope `{scope}`: {domain} is a public suffix"
500        ));
501    }
502    Ok(())
503}
504
505/// Whether a pool whose on-chain owner is `on_chain` accepts a capability its
506/// signature recovers to `signed_by`.
507fn owner_matches(on_chain: Address, signed_by: Address, pool_id: PoolId) -> Result<(), String> {
508    if on_chain.is_zero() {
509        Err(format!(
510            "pool {pool_id} does not exist on this deployment\n"
511        ))
512    } else if on_chain != signed_by {
513        Err(format!(
514            "the capability is not signed by the owner of pool {pool_id}\n"
515        ))
516    } else {
517        Ok(())
518    }
519}
520
521/// A delegate key and the capability registered to it, if any.
522struct Delegate {
523    signer: Arc<PrivateKeySigner>,
524    minted: Instant,
525    /// The site of the page that minted it, or `None` for the token holder.
526    site: Option<String>,
527    capability: Option<Registered>,
528}
529
530/// Whether a page of `site` may mint one more delegate beside `delegates`.
531/// Pages share [`MAX_PAGE_DELEGATES`] and each site holds at most
532/// [`MAX_DELEGATES_PER_SITE`], pending or registered; the token holder
533/// (`None`) mints outside both bounds, so no page can stop the user's own
534/// handshake.
535fn may_mint(delegates: &HashMap<Address, Delegate>, site: Option<&str>) -> Result<(), String> {
536    let Some(site) = site else {
537        return Ok(());
538    };
539    let pages = || delegates.values().filter(|d| d.site.is_some());
540    if pages().count() >= MAX_PAGE_DELEGATES {
541        return Err("too many pages hold delegates\n".to_owned());
542    }
543    if pages().filter(|d| d.site.as_deref() == Some(site)).count() >= MAX_DELEGATES_PER_SITE {
544        return Err(format!(
545            "pages of {site} hold {MAX_DELEGATES_PER_SITE} delegates\n"
546        ));
547    }
548    Ok(())
549}
550
551struct Registered {
552    grant: CapabilityGrant,
553    scope: Scope,
554    /// The origin that registered it, or `None` for the token holder.
555    origin: Option<String>,
556    /// When it was registered.
557    since: Instant,
558    spent: bool,
559}
560
561impl Registered {
562    /// The order among capabilities that cover one host, highest first: the
563    /// more specific scope, then the token holder's own registration over a
564    /// page's, then the earlier registration, so a later one never shadows
565    /// a capability already in use.
566    const fn preference(&self) -> (u8, bool, std::cmp::Reverse<Instant>) {
567        (
568            self.scope.rank(),
569            self.origin.is_none(),
570            std::cmp::Reverse(self.since),
571        )
572    }
573}
574
575#[derive(serde::Deserialize)]
576struct RegisterRequest {
577    capability: String,
578    scope: String,
579}
580
581struct Gateway {
582    common: ClientFetchArgs,
583    token: String,
584    /// The chain a registered capability's pool must live on.
585    chain: ChainConfig,
586    /// The EIP-712 domain a capability's owner signs under.
587    domain: Eip712Domain,
588    delegates: Mutex<HashMap<Address, Delegate>>,
589    /// Runs every pull, on one endpoint and one buyer-pool store.
590    pulls: PullWorker,
591    manifests: std::sync::Mutex<ManifestCache>,
592    /// Held while a missing manifest is pulled, so concurrent requests for it
593    /// pull and pay once. Cache hits never wait on it.
594    manifest_fill: Mutex<()>,
595    hosts: Vec<String>,
596}
597
598/// Parsed bundle manifests by hash, least recently used first out, within
599/// [`MAX_CACHED_MANIFESTS`] entries and [`MANIFEST_CACHE_BYTES`].
600#[derive(Default)]
601struct ManifestCache {
602    /// Oldest first: each entry is the hash, the manifest and its JSON size.
603    entries: std::collections::VecDeque<([u8; 32], Arc<BundleManifest>, usize)>,
604    bytes: usize,
605}
606
607impl ManifestCache {
608    fn get(&mut self, hash: &[u8; 32]) -> Option<Arc<BundleManifest>> {
609        let at = self.entries.iter().position(|(h, _, _)| h == hash)?;
610        let entry = self.entries.remove(at)?;
611        let manifest = Arc::clone(&entry.1);
612        self.entries.push_back(entry);
613        Some(manifest)
614    }
615
616    fn insert(&mut self, hash: [u8; 32], manifest: Arc<BundleManifest>, size: usize) {
617        if let Some(at) = self.entries.iter().position(|(h, _, _)| *h == hash)
618            && let Some((_, _, old)) = self.entries.remove(at)
619        {
620            self.bytes = self.bytes.saturating_sub(old);
621        }
622        self.entries.push_back((hash, manifest, size));
623        self.bytes = self.bytes.saturating_add(size);
624        while self.entries.len() > 1
625            && (self.entries.len() > MAX_CACHED_MANIFESTS || self.bytes > MANIFEST_CACHE_BYTES)
626        {
627            if let Some((_, _, evicted)) = self.entries.pop_front() {
628                self.bytes = self.bytes.saturating_sub(evicted);
629            }
630        }
631    }
632}
633
634/// What a pull that ended badly means for the gateway.
635enum PullError {
636    /// The capability cannot pay for the rest.
637    Funding(anyhow::Error),
638    /// The blob cannot be read as a bundle manifest: it is over the manifest
639    /// cap, or its bytes do not parse as one. Nothing upstream failed.
640    NotManifest(anyhow::Error),
641    Other(anyhow::Error),
642}
643
644/// The capability a content request pays with.
645struct Paying {
646    signer: Arc<PrivateKeySigner>,
647    grant: CapabilityGrant,
648}
649
650impl Gateway {
651    async fn handle<B>(self: Arc<Self>, req: Request<B>) -> Response<Body>
652    where
653        B: HttpBody<Data = Bytes>,
654        B::Error: std::error::Error + Send + Sync + 'static,
655    {
656        let host = req
657            .headers()
658            .get(header::HOST)
659            .and_then(|h| h.to_str().ok())
660            .unwrap_or("");
661        if !self.hosts.iter().any(|allowed| allowed == host) {
662            return text(StatusCode::FORBIDDEN, "unknown Host\n");
663        }
664        let origin = req
665            .headers()
666            .get(header::ORIGIN)
667            .and_then(|o| o.to_str().ok())
668            .map(str::to_owned);
669        let authorized = self.authorized(req.headers());
670        let path = req.uri().path().to_owned();
671        let method = req.method().clone();
672        match (method, path.as_str()) {
673            (Method::OPTIONS, "/_delegate" | "/_capability") => preflight(origin.as_deref()),
674            (Method::OPTIONS, _) => text(StatusCode::FORBIDDEN, "no cross-origin access\n"),
675            (Method::GET, "/") => html(
676                StatusCode::OK,
677                "<h1>decdn gateway</h1><p>Running. Connect the deCDN browser extension with \
678                 the token <code>decdn gateway</code> printed when it started.</p>",
679            ),
680            (Method::GET, "/favicon.ico") => text(StatusCode::NOT_FOUND, ""),
681            (Method::GET, "/_delegate") => {
682                let response = match Caller::of(origin.as_deref(), authorized) {
683                    Ok(caller) => self.mint(&caller).await,
684                    Err(e) => text(StatusCode::FORBIDDEN, e),
685                };
686                with_cors(response, origin.as_deref())
687            }
688            (Method::PUT, "/_capability") => {
689                let response = match Caller::of(origin.as_deref(), authorized) {
690                    Ok(caller) => self.register(req, &caller).await,
691                    Err(e) => text(StatusCode::FORBIDDEN, e),
692                };
693                with_cors(response, origin.as_deref())
694            }
695            (Method::GET, "/_capability") if authorized => self.list().await,
696            (Method::GET, "/_capability") => text(StatusCode::UNAUTHORIZED, "token required\n"),
697            // A HEAD answer would cost a paid pull and then drop its bytes.
698            (Method::HEAD, _) => {
699                let mut response = text(StatusCode::METHOD_NOT_ALLOWED, "");
700                response
701                    .headers_mut()
702                    .insert(header::ALLOW, header::HeaderValue::from_static("GET"));
703                response
704            }
705            (Method::GET, _) => {
706                let target = req
707                    .uri()
708                    .path_and_query()
709                    .map_or("/", |pq| pq.as_str())
710                    .to_owned();
711                self.content(req.headers(), authorized, target.trim_start_matches('/'))
712                    .await
713            }
714            _ => text(StatusCode::METHOD_NOT_ALLOWED, "method not allowed\n"),
715        }
716    }
717
718    fn authorized(&self, headers: &HeaderMap) -> bool {
719        headers
720            .get(TOKEN_HEADER)
721            .and_then(|t| t.to_str().ok())
722            .is_some_and(|given| token_matches(&self.token, given))
723    }
724
725    /// Drop delegates that waited too long for a capability, and those whose
726    /// capability expired.
727    fn prune(delegates: &mut HashMap<Address, Delegate>) {
728        let now = now_secs();
729        delegates.retain(|_, d| match &d.capability {
730            None => d.minted.elapsed() < PENDING_TTL,
731            Some(registered) => registered.grant.expiry > now,
732        });
733    }
734
735    async fn mint(&self, caller: &Caller) -> Response<Body> {
736        let mut delegates = self.delegates.lock().await;
737        Self::prune(&mut delegates);
738        let site = caller.site();
739        if let Err(e) = may_mint(&delegates, site.as_deref()) {
740            return text(StatusCode::TOO_MANY_REQUESTS, &e);
741        }
742        let signer = PrivateKeySigner::random();
743        let address = signer.address();
744        delegates.insert(
745            address,
746            Delegate {
747                signer: Arc::new(signer),
748                minted: Instant::now(),
749                site,
750                capability: None,
751            },
752        );
753        json(
754            StatusCode::OK,
755            &serde_json::json!({ "address": address.to_string() }),
756        )
757    }
758
759    async fn register<B>(&self, req: Request<B>, caller: &Caller) -> Response<Body>
760    where
761        B: HttpBody<Data = Bytes>,
762        B::Error: std::error::Error + Send + Sync + 'static,
763    {
764        let body = match Limited::new(req.into_body(), MAX_BODY_BYTES)
765            .collect()
766            .await
767        {
768            Ok(body) => body.to_bytes(),
769            Err(_) => return text(StatusCode::PAYLOAD_TOO_LARGE, "body too large\n"),
770        };
771        let request: RegisterRequest = match serde_json::from_slice(&body) {
772            Ok(request) => request,
773            Err(e) => {
774                return text(
775                    StatusCode::UNPROCESSABLE_ENTITY,
776                    &format!("expected {{\"capability\", \"scope\"}}: {e}\n"),
777                );
778            }
779        };
780        let scope = match Scope::parse(&request.scope) {
781            Ok(scope) => scope,
782            Err(e) => return text(StatusCode::UNPROCESSABLE_ENTITY, &format!("{e}\n")),
783        };
784        if let Err(e) = may_register(caller, &scope) {
785            return text(StatusCode::FORBIDDEN, &format!("{e}\n"));
786        }
787        let grant = match CapabilityGrant::from_token(request.capability.trim()) {
788            Ok(grant) => grant,
789            Err(e) => return text(StatusCode::UNPROCESSABLE_ENTITY, &format!("{e}\n")),
790        };
791        if grant.expiry <= now_secs() {
792            return text(
793                StatusCode::UNPROCESSABLE_ENTITY,
794                "the capability has expired\n",
795            );
796        }
797        if let Err((status, e)) = self.verify_owner(&grant).await {
798            return text(status, &e);
799        }
800        let mut delegates = self.delegates.lock().await;
801        Self::prune(&mut delegates);
802        let Some(delegate) = delegates.get_mut(&grant.signer) else {
803            return text(
804                StatusCode::CONFLICT,
805                "the capability names no delegate this gateway is holding: get one from \
806                 GET /_delegate\n",
807            );
808        };
809        if delegate.capability.is_some() {
810            return text(
811                StatusCode::CONFLICT,
812                "that delegate already holds a capability: get a new one from GET /_delegate\n",
813            );
814        }
815        let response = serde_json::json!({
816            "signer": grant.signer.to_string(),
817            "scope": scope.to_string(),
818            "expiry": grant.expiry,
819        });
820        delegate.capability = Some(Registered {
821            grant,
822            scope,
823            origin: caller.origin().map(str::to_owned),
824            since: Instant::now(),
825            spent: false,
826        });
827        json(StatusCode::CREATED, &response)
828    }
829
830    /// Check that `grant` is signed by the on-chain owner of its pool on this
831    /// gateway's deployment. Whether the pool can pay shows only when a pull
832    /// draws on it: a funding stop retires the capability.
833    async fn verify_owner(&self, grant: &CapabilityGrant) -> Result<(), (StatusCode, String)> {
834        let unprocessable = |e: String| (StatusCode::UNPROCESSABLE_ENTITY, e);
835        let signed_by = grant
836            .owner(&self.domain)
837            .map_err(|e| unprocessable(format!("{e}\n")))?;
838        let unreachable = |e: String| {
839            (
840                StatusCode::BAD_GATEWAY,
841                format!("could not read the capability's pool on-chain: {e}\n"),
842            )
843        };
844        let rpc = decdn_client::provider::build_read_provider(&self.chain.rpc_url)
845            .map_err(|e| unreachable(sanitize_err_chain(&e)))?;
846        let pool = PaymentPool::new(self.chain.payment_pool, rpc)
847            .getPool(grant.pool_id)
848            .call()
849            .await
850            .map_err(|e| unreachable(sanitize_error_sources(&e)))?;
851        owner_matches(pool.owner, signed_by, grant.pool_id).map_err(unprocessable)
852    }
853
854    async fn list(&self) -> Response<Body> {
855        let mut delegates = self.delegates.lock().await;
856        Self::prune(&mut delegates);
857        let list: Vec<_> = delegates
858            .values()
859            .filter_map(|d| d.capability.as_ref())
860            .map(|r| {
861                serde_json::json!({
862                    "signer": r.grant.signer.to_string(),
863                    "pool": r.grant.pool_id.to_string(),
864                    "scope": r.scope.to_string(),
865                    "origin": r.origin,
866                    "cap": r.grant.spending_cap,
867                    "expiry": r.grant.expiry,
868                    "spent": r.spent,
869                })
870            })
871            .collect();
872        json(StatusCode::OK, &serde_json::Value::Array(list))
873    }
874
875    /// The live capability whose scope covers `host` most specifically, by
876    /// [`Registered::preference`].
877    async fn paying_for(&self, host: &str) -> Option<Paying> {
878        let mut delegates = self.delegates.lock().await;
879        Self::prune(&mut delegates);
880        delegates
881            .values()
882            .filter_map(|d| d.capability.as_ref().map(|r| (d, r)))
883            .filter(|(_, r)| !r.spent && r.scope.covers(host))
884            .max_by_key(|(_, r)| r.preference())
885            .map(|(d, r)| Paying {
886                signer: Arc::clone(&d.signer),
887                grant: r.grant.clone(),
888            })
889    }
890
891    /// Retire the capability `signer` pays with after a funding stop. A page's
892    /// is dropped, freeing its origin's room; the token holder's stays listed
893    /// as spent.
894    async fn mark_spent(&self, signer: Address) {
895        let mut delegates = self.delegates.lock().await;
896        let Some(registered) = delegates
897            .get_mut(&signer)
898            .and_then(|d| d.capability.as_mut())
899        else {
900            return;
901        };
902        if registered.origin.is_some() {
903            delegates.remove(&signer);
904        } else {
905            registered.spent = true;
906        }
907    }
908
909    async fn content(
910        self: &Arc<Self>,
911        headers: &HeaderMap,
912        authorized: bool,
913        rest: &str,
914    ) -> Response<Body> {
915        if !authorized {
916            return text(StatusCode::UNAUTHORIZED, "token required\n");
917        }
918        let Some(page_host) = headers
919            .get(HOST_HEADER)
920            .and_then(|h| h.to_str().ok())
921            .map(str::to_ascii_lowercase)
922        else {
923            return text(StatusCode::UNAUTHORIZED, "page host required\n");
924        };
925        let uri: DecdnUri = match format!("decdn:{rest}").parse() {
926            Ok(uri) => uri,
927            Err(e) => return text(StatusCode::BAD_REQUEST, &format!("{e}\n")),
928        };
929        // A funding stop retires the capability and the next covering one
930        // takes the request, so a capability that cannot pay costs a retry,
931        // never a failed load. Each pass retires one, so the loop ends.
932        loop {
933            let Some(paying) = self.paying_for(&page_host).await else {
934                return text(
935                    StatusCode::PAYMENT_REQUIRED,
936                    &format!("no capability covers {page_host}\n"),
937                );
938            };
939            let result = match &uri.target {
940                UriTarget::Blob => {
941                    self.stream(uri.hash, uri.namespace, &paying, None, None)
942                        .await
943                }
944                UriTarget::BundleEntry(path) => {
945                    self.entry(uri.hash, uri.namespace, path, &paying).await
946                }
947                UriTarget::Bundle => self.index(uri.hash, uri.namespace, &paying).await,
948                _ => return text(StatusCode::BAD_REQUEST, "unsupported URI target\n"),
949            };
950            match result {
951                Ok(response) => return response,
952                Err(PullError::Funding(e)) => {
953                    tracing::info!(error = %e, host = %page_host, "a capability ran out");
954                    self.mark_spent(paying.signer.address()).await;
955                }
956                Err(PullError::NotManifest(e)) => {
957                    return text(StatusCode::NOT_FOUND, &format!("{e:#}\n"));
958                }
959                Err(PullError::Other(e)) => {
960                    return text(StatusCode::BAD_GATEWAY, &format!("{e:#}\n"));
961                }
962            }
963        }
964    }
965
966    /// One bundle entry, by its literal manifest path.
967    async fn entry(
968        self: &Arc<Self>,
969        manifest_hash: [u8; 32],
970        namespace: Option<u64>,
971        path: &str,
972        paying: &Paying,
973    ) -> Result<Response<Body>, PullError> {
974        let manifest = self.manifest(manifest_hash, namespace, paying).await?;
975        let Some(entry) = manifest.entries.iter().find(|e| e.path == path) else {
976            return Ok(text(
977                StatusCode::NOT_FOUND,
978                &format!("the bundle has no entry {path:?}\n"),
979            ));
980        };
981        let hash = parse_hash(&entry.hash)
982            .with_context(|| format!("bundle entry {path:?} has a malformed hash"))
983            .map_err(PullError::Other)?;
984        self.stream(hash, namespace, paying, Some(path), entry.size)
985            .await
986    }
987
988    /// An HTML index of a bundle's entries, each linking to its gateway URL.
989    async fn index(
990        self: &Arc<Self>,
991        manifest_hash: [u8; 32],
992        namespace: Option<u64>,
993        paying: &Paying,
994    ) -> Result<Response<Body>, PullError> {
995        let manifest = self.manifest(manifest_hash, namespace, paying).await?;
996        let mut rows = String::new();
997        for entry in &manifest.entries {
998            let link = DecdnUri::new(
999                manifest_hash,
1000                UriTarget::BundleEntry(entry.path.clone()),
1001                namespace,
1002            )
1003            .to_string();
1004            let size = entry.size.map_or_else(String::new, human_bytes);
1005            let _ = write!(
1006                rows,
1007                "<tr><td><a href=\"{}\">{}</a></td><td>{size}</td></tr>",
1008                escape(&link),
1009                escape(&entry.path),
1010            );
1011        }
1012        Ok(html(
1013            StatusCode::OK,
1014            &format!(
1015                "<h1>Bundle</h1><p><code>{}</code></p><table>{rows}</table>",
1016                escape(&DecdnUri::new(manifest_hash, UriTarget::Bundle, namespace).to_string())
1017            ),
1018        ))
1019    }
1020
1021    fn cached_manifest(&self, hash: &[u8; 32]) -> Option<Arc<BundleManifest>> {
1022        self.manifests
1023            .lock()
1024            .unwrap_or_else(std::sync::PoisonError::into_inner)
1025            .get(hash)
1026    }
1027
1028    /// The bundle manifest `hash`, from the cache or pulled once. The pull
1029    /// requests, and so pays for, at most the manifest cap.
1030    async fn manifest(
1031        self: &Arc<Self>,
1032        hash: [u8; 32],
1033        namespace: Option<u64>,
1034        paying: &Paying,
1035    ) -> Result<Arc<BundleManifest>, PullError> {
1036        if let Some(manifest) = self.cached_manifest(&hash) {
1037            return Ok(manifest);
1038        }
1039        let _fill = self.manifest_fill.lock().await;
1040        // A request that waited on the fill lock finds what the one before it
1041        // pulled.
1042        if let Some(manifest) = self.cached_manifest(&hash) {
1043            return Ok(manifest);
1044        }
1045        let cap_mb = manifest_cap_mb(self.common.max_blob_mb);
1046        let raw = raw_blob_hint(hash, namespace);
1047        // The raw blob pulls under the gateway's own cap, so it is a way past
1048        // the manifest cap only when that cap is higher.
1049        let raw_past_cap = (self.common.max_blob_mb == 0 || cap_mb < self.common.max_blob_mb)
1050            .then_some(raw.as_str());
1051        let (first, mut reader, done) = self
1052            .start_pull(hash, namespace, paying, cap_mb)
1053            .await
1054            .map_err(|e| over_manifest_cap(e, cap_mb, raw_past_cap))?;
1055        let mut bytes = first.to_vec();
1056        let mut buf = vec![0u8; CHUNK_BYTES];
1057        loop {
1058            let n = reader
1059                .read(&mut buf)
1060                .await
1061                .map_err(|e| PullError::Other(e.into()))?;
1062            if n == 0 {
1063                break;
1064            }
1065            bytes.extend_from_slice(buf.get(..n).unwrap_or_default());
1066            // A backstop: the manifest cap ends the pull before this.
1067            if bytes.len() > MAX_MANIFEST_BYTES {
1068                return Err(PullError::NotManifest(anyhow::anyhow!(
1069                    "the blob is larger than {MAX_MANIFEST_BYTES} bytes, the most this \
1070                     gateway reads as a bundle manifest"
1071                )));
1072            }
1073        }
1074        finished(done)
1075            .await
1076            .map_err(|e| over_manifest_cap(e, cap_mb, raw_past_cap))?;
1077        // The blob fits under the manifest cap, so it fits under the gateway's.
1078        let manifest = Arc::new(
1079            BundleManifest::parse(&bytes)
1080                .with_context(|| format!("not a bundle manifest; {raw}"))
1081                .map_err(PullError::NotManifest)?,
1082        );
1083        self.manifests
1084            .lock()
1085            .unwrap_or_else(std::sync::PoisonError::into_inner)
1086            .insert(hash, Arc::clone(&manifest), bytes.len());
1087        Ok(manifest)
1088    }
1089
1090    /// A response streaming `hash`'s verified bytes. Errors that come before
1091    /// the first byte become the error status; a later one aborts the body.
1092    async fn stream(
1093        self: &Arc<Self>,
1094        hash: [u8; 32],
1095        namespace: Option<u64>,
1096        paying: &Paying,
1097        path: Option<&str>,
1098        size: Option<u64>,
1099    ) -> Result<Response<Body>, PullError> {
1100        let (first, reader, done) = self
1101            .start_pull(hash, namespace, paying, self.common.max_blob_mb)
1102            .await?;
1103        let state = (Some(first), reader, Some(done));
1104        let gateway = Arc::clone(self);
1105        let signer = paying.signer.address();
1106        let body = futures_util::stream::unfold(state, move |(first, mut reader, done)| {
1107            let gateway = Arc::clone(&gateway);
1108            async move {
1109                if let Some(first) = first {
1110                    return Some((Ok(Frame::data(first)), (None, reader, done)));
1111                }
1112                let mut buf = vec![0u8; CHUNK_BYTES];
1113                match reader.read(&mut buf).await {
1114                    Ok(0) => match finished(done?).await {
1115                        Ok(()) => None,
1116                        // The verified prefix is already out: abort the body so the
1117                        // browser does not take it for the whole file.
1118                        Err(e) => {
1119                            if matches!(e, PullError::Funding(_)) {
1120                                gateway.mark_spent(signer).await;
1121                            }
1122                            Some((
1123                                Err(std::io::Error::other(pull_error_text(&e))),
1124                                (None, reader, None),
1125                            ))
1126                        }
1127                    },
1128                    Ok(n) => {
1129                        buf.truncate(n);
1130                        Some((Ok(Frame::data(Bytes::from(buf))), (None, reader, done)))
1131                    }
1132                    Err(e) => Some((Err(e), (None, reader, None))),
1133                }
1134            }
1135        });
1136        let name = path
1137            .and_then(|p| p.rsplit('/').next())
1138            .map_or_else(|| alloy::hex::encode(hash), str::to_owned);
1139        let mut response = Response::builder()
1140            .status(StatusCode::OK)
1141            .header(header::CONTENT_TYPE, content_type(&name))
1142            .header(
1143                header::CONTENT_DISPOSITION,
1144                format!("inline; filename=\"{}\"", name.replace(['"', '\\'], "_")),
1145            )
1146            .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff");
1147        if let Some(size) = size {
1148            response = response.header(header::CONTENT_LENGTH, size);
1149        }
1150        response
1151            .body(StreamBody::new(body).boxed())
1152            .map_err(|e| PullError::Other(e.into()))
1153    }
1154
1155    /// Hand the pull of `hash` to the worker, capped at `max_blob_mb`, and wait
1156    /// for its first verified bytes. The pull writes into a pipe the response
1157    /// reads from.
1158    async fn start_pull(
1159        self: &Arc<Self>,
1160        hash: [u8; 32],
1161        namespace: Option<u64>,
1162        paying: &Paying,
1163        max_blob_mb: u64,
1164    ) -> Result<(Bytes, DuplexStream, oneshot::Receiver<anyhow::Result<()>>), PullError> {
1165        let (writer, mut reader) = tokio::io::duplex(PIPE_BYTES);
1166        let (done_tx, done_rx) = oneshot::channel();
1167        let mut common = self.common.clone();
1168        common.max_blob_mb = max_blob_mb;
1169        self.pulls
1170            .pull(Job {
1171                blob: BlobFetch {
1172                    hash,
1173                    output: PathBuf::from("-"),
1174                    namespace,
1175                    common,
1176                },
1177                paying: Credential {
1178                    signer: Arc::clone(&paying.signer),
1179                    grant: paying.grant.clone(),
1180                },
1181                writer,
1182                done: done_tx,
1183            })
1184            .map_err(PullError::Other)?;
1185        let (first, done_rx) = first_chunk(&mut reader, done_rx).await?;
1186        Ok((first, reader, done_rx))
1187    }
1188}
1189
1190/// The pull's first verified bytes, or the error it ended with before any.
1191///
1192/// The pull closes the pipe before it reports its result, so an end of
1193/// stream with no bytes waits for that result: a failed pull is an error
1194/// here, and a successful one is an empty body.
1195async fn first_chunk(
1196    reader: &mut DuplexStream,
1197    done_rx: oneshot::Receiver<anyhow::Result<()>>,
1198) -> Result<(Bytes, oneshot::Receiver<anyhow::Result<()>>), PullError> {
1199    let mut first = vec![0u8; CHUNK_BYTES];
1200    let n = reader
1201        .read(&mut first)
1202        .await
1203        .map_err(|e| PullError::Other(e.into()))?;
1204    if n > 0 {
1205        first.truncate(n);
1206        return Ok((Bytes::from(first), done_rx));
1207    }
1208    finished(done_rx).await?;
1209    // The body's end-of-stream check then finds the pull already done.
1210    let (spent_tx, spent_rx) = oneshot::channel();
1211    let _ = spent_tx.send(Ok(()));
1212    Ok((Bytes::new(), spent_rx))
1213}
1214
1215/// The pull's own result once its bytes are drained.
1216async fn finished(done: oneshot::Receiver<anyhow::Result<()>>) -> Result<(), PullError> {
1217    match done.await {
1218        Ok(Ok(())) => Ok(()),
1219        Ok(Err(e)) => Err(classify(e)),
1220        // The pull thread ended without a result: it panicked, so the bytes
1221        // it wrote may be short.
1222        Err(_) => Err(PullError::Other(anyhow::anyhow!(
1223            "the pull ended without a result"
1224        ))),
1225    }
1226}
1227
1228/// A pull error is a funding stop when [`decdn_client::FundingNeeded`] is
1229/// attached anywhere on it, as context or as the error itself.
1230fn classify(e: anyhow::Error) -> PullError {
1231    if e.downcast_ref::<decdn_client::FundingNeeded>().is_some() {
1232        PullError::Funding(e)
1233    } else {
1234        PullError::Other(e)
1235    }
1236}
1237
1238/// Mark a manifest pull that the cap of `cap_mb` ended as
1239/// [`PullError::NotManifest`]: the gateway reads no larger blob as a manifest.
1240/// `raw` is the hint to fetch the raw blob, when the gateway serves it.
1241fn over_manifest_cap(e: PullError, cap_mb: u64, raw: Option<&str>) -> PullError {
1242    match e {
1243        PullError::Other(e) if e.downcast_ref::<decdn_client::BlobTooLarge>().is_some() => {
1244            let hint = raw.map_or_else(String::new, |raw| format!("; {raw}"));
1245            PullError::NotManifest(e.context(format!(
1246                "the blob is larger than {cap_mb} MiB, the most this gateway reads as a \
1247                 bundle manifest{hint}"
1248            )))
1249        }
1250        e => e,
1251    }
1252}
1253
1254/// The hint to fetch blob `hash` raw, at its gateway path.
1255fn raw_blob_hint(hash: [u8; 32], namespace: Option<u64>) -> String {
1256    let uri = DecdnUri::new(hash, UriTarget::Blob, namespace).to_string();
1257    let path = uri.strip_prefix(decdn_client::uri::SCHEME).unwrap_or(&uri);
1258    format!("fetch `/{path}` for the raw blob")
1259}
1260
1261fn pull_error_text(e: &PullError) -> String {
1262    match e {
1263        PullError::Funding(e) | PullError::NotManifest(e) | PullError::Other(e) => {
1264            format!("{e:#}")
1265        }
1266    }
1267}
1268
1269/// The CORS preflight answer for `/_delegate` and `/_capability`: any origin
1270/// may call them, with a JSON body.
1271fn preflight(origin: Option<&str>) -> Response<Body> {
1272    let Some(origin) = origin else {
1273        return text(StatusCode::FORBIDDEN, "no Origin\n");
1274    };
1275    let mut response = with_cors(text(StatusCode::NO_CONTENT, ""), Some(origin));
1276    let headers = response.headers_mut();
1277    headers.insert(
1278        header::ACCESS_CONTROL_ALLOW_METHODS,
1279        header::HeaderValue::from_static("GET, PUT"),
1280    );
1281    headers.insert(
1282        header::ACCESS_CONTROL_ALLOW_HEADERS,
1283        header::HeaderValue::from_static("content-type"),
1284    );
1285    headers.insert(
1286        header::ACCESS_CONTROL_MAX_AGE,
1287        header::HeaderValue::from_static("600"),
1288    );
1289    // Chrome asks before a public page reaches a loopback address.
1290    headers.insert(
1291        "access-control-allow-private-network",
1292        header::HeaderValue::from_static("true"),
1293    );
1294    response
1295}
1296
1297/// `response` readable by `origin`.
1298fn with_cors(mut response: Response<Body>, origin: Option<&str>) -> Response<Body> {
1299    if let Some(value) = origin.and_then(|o| header::HeaderValue::from_str(o).ok()) {
1300        let headers = response.headers_mut();
1301        headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, value);
1302        headers.insert(header::VARY, header::HeaderValue::from_static("Origin"));
1303    }
1304    response
1305}
1306
1307/// Types a browser runs or renders as a document. The gateway serves them as
1308/// plain text, so fetched content never runs on the gateway's origin.
1309const ACTIVE_TYPES: &[&str] = &[
1310    "application/xhtml+xml",
1311    "image/svg+xml",
1312    "application/xml",
1313    "application/javascript",
1314    "application/ecmascript",
1315];
1316
1317/// The `Content-Type` for an entry, guessed from its file name. Every `text/*`
1318/// type and every type in [`ACTIVE_TYPES`] is served as plain text; a name
1319/// with no known extension is served as bytes.
1320fn content_type(name: &str) -> String {
1321    let guess = mime_guess::from_path(name).first_or_octet_stream();
1322    if guess.type_() == mime_guess::mime::TEXT || ACTIVE_TYPES.contains(&guess.essence_str()) {
1323        "text/plain; charset=utf-8".to_owned()
1324    } else {
1325        guess.essence_str().to_owned()
1326    }
1327}
1328
1329fn full(body: String) -> Body {
1330    Full::new(Bytes::from(body))
1331        .map_err(|never| match never {})
1332        .boxed()
1333}
1334
1335fn text(status: StatusCode, body: &str) -> Response<Body> {
1336    typed(status, body.to_owned(), "text/plain; charset=utf-8")
1337}
1338
1339fn json(status: StatusCode, value: &serde_json::Value) -> Response<Body> {
1340    typed(status, value.to_string(), "application/json")
1341}
1342
1343fn html(status: StatusCode, body: &str) -> Response<Body> {
1344    let page = format!(
1345        "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" \
1346         content=\"width=device-width\"><title>decdn gateway</title><style>body{{font-family:\
1347         system-ui,sans-serif;max-width:48rem;margin:3rem auto;padding:0 1rem}}td{{padding:\
1348         .2rem 1rem .2rem 0}}</style></head><body>{body}</body></html>"
1349    );
1350    typed(status, page, "text/html; charset=utf-8")
1351}
1352
1353fn typed(status: StatusCode, body: String, content_type: &'static str) -> Response<Body> {
1354    let mut response = Response::new(full(body));
1355    *response.status_mut() = status;
1356    response.headers_mut().insert(
1357        header::CONTENT_TYPE,
1358        header::HeaderValue::from_static(content_type),
1359    );
1360    response
1361}
1362
1363fn escape(s: &str) -> String {
1364    s.replace('&', "&amp;")
1365        .replace('<', "&lt;")
1366        .replace('>', "&gt;")
1367        .replace('"', "&quot;")
1368        .replace('\'', "&#39;")
1369}
1370
1371fn human_bytes(n: u64) -> String {
1372    indicatif::HumanBytes(n).to_string()
1373}
1374
1375#[cfg(test)]
1376#[allow(
1377    clippy::unwrap_used,
1378    clippy::expect_used,
1379    clippy::indexing_slicing,
1380    clippy::panic
1381)]
1382mod tests;