src/api/assets.rs
Rama main · réplica de lectura del repositorio original.
//! Content-addressed serving for the browser widget bundle.
//!
//! The widget is not one file. `captcha-widget.js` fetches
//! `captcha-worker.js` at runtime, which in turn pulls
//! `vendor/argon2.umd.min.js`, and the stylesheet is a fourth request. Those
//! are four independent browser cache entries that all have to agree with
//! each other *and* with the server's wire format — the worker's
//! `solveArgon2id` has to match `compute_argon2id`, and the widget has to
//! understand whatever `algorithm` the puzzle response names.
//!
//! Served from plain unversioned paths with no `Cache-Control`, browsers
//! apply heuristic freshness, so a visitor could hold a new widget against a
//! stale worker for an unbounded window after any deploy that touched the PoW
//! contract. Switching the default algorithm to Argon2id is exactly that
//! shape of change: a cached SHA-256-only worker cannot solve the puzzle it
//! gets handed, and no server-side rollback reaches it.
//!
//! The fix is the shape Turnstile and hCaptcha use. One *mutable* entry point
//! at a stable URL integrators embed forever (`/v1/widget.js`, short TTL),
//! pointing at an *immutable* directory keyed by a hash of the asset contents
//! (`/assets/<hash>/…`, cached for a year). Any change to any asset changes
//! the hash, so the bundle moves atomically and a half-updated combination is
//! not representable. A build that changes no asset keeps the hash, so
//! redeploys don't needlessly evict warm caches — which a git-SHA stamp would
//! do on every commit, and which `.dockerignore` excluding `/.git` would make
//! awkward to compute in the image anyway.
use std::path::Path;
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use sha2::{Digest, Sha256};
/// Files whose contents determine the bundle hash.
///
/// Every asset the widget pulls at runtime belongs here: if a file can change
/// the client's behaviour but is not hashed, the version stops being a
/// truthful description of the bundle and stale-mixing comes back. The
/// operator-facing pages under `static/` (`admin.html`, `testsite.html`, the
/// info pages) are deliberately absent — nothing loads them as part of a
/// widget, they keep being served from `/static/`, and including them would
/// churn the hash for edits that cannot affect an embed.
const HASHED_ASSETS: [&str; 4] = [
"captcha-widget.js",
"captcha-worker.js",
"captcha-widget.css",
"vendor/argon2.umd.min.js",
];
/// Token in `captcha-widget.js` that the entry-point route rewrites to the
/// hashed asset directory.
///
/// The same file is also served verbatim from `/static/` (the legacy embed
/// path) and from inside `/assets/<hash>/`, where no substitution happens.
/// The widget therefore treats an unsubstituted placeholder as "resolve my
/// siblings relative to my own URL" — see `resolveAssetBase` in the widget.
/// That keeps one source file correct on all three paths instead of needing a
/// build step to emit variants.
const ASSET_BASE_PLACEHOLDER: &str = "__BOLLWARK_ASSET_BASE__";
/// URL prefix under which the immutable, content-hashed bundle is mounted.
pub const ASSET_MOUNT: &str = "/assets";
/// `Cache-Control` for the mutable entry point.
///
/// Short rather than `no-store`: the entry point is on the critical path of
/// every embed, so it wants to be cacheable, but five minutes bounds how long
/// a bad deploy can keep reaching visitors. This is the only asset URL whose
/// contents ever change, which is what makes the year-long TTL below safe.
const ENTRY_CACHE_CONTROL: &str = "public, max-age=300";
/// `Cache-Control` for everything under `/assets/<hash>/`.
///
/// Safe at a year precisely because the path contains the content hash: a
/// changed file is a different URL, never a stale hit.
pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
/// `Cache-Control` for the legacy unversioned `/static/` tree.
///
/// Same bound as the entry point, and for the same reason: nothing under
/// `/static/` is content-addressed, so this is the longest a stale copy can
/// outlive a deploy. Before it existed the answer was "however long the
/// browser's heuristic freshness decides", which is what made a widget/worker
/// version skew unbounded.
pub const LEGACY_CACHE_CONTROL: &str = ENTRY_CACHE_CONTROL;
/// The widget entry point, resolved once at router construction.
pub struct WidgetBundle {
/// Short content hash over [`HASHED_ASSETS`], used as the immutable path
/// segment.
pub version: String,
/// `captcha-widget.js` with [`ASSET_BASE_PLACEHOLDER`] substituted for
/// this bundle's asset directory.
source: String,
}
impl WidgetBundle {
/// Read and hash the bundle out of `static_dir`.
///
/// Returns `None` if any hashed asset is missing or unreadable, which the
/// caller treats as "don't mount the versioned routes at all". Degrading
/// to no entry point is better than serving one that points at an asset
/// directory we could not verify; the legacy `/static/` paths keep
/// working either way, so a partial `STATIC_DIR` still yields a
/// functioning (if unversioned) widget rather than a boot failure.
pub fn load(static_dir: &str) -> Option<Self> {
let dir = Path::new(static_dir);
let mut hasher = Sha256::new();
for name in HASHED_ASSETS {
let path = dir.join(name);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(
asset = name,
path = %path.display(),
error = %e,
"widget asset unreadable; serving unversioned /static assets only"
);
return None;
}
};
// Length-prefix each entry so that two different splits of the
// same total byte stream can't collide onto one hash.
hasher.update(name.as_bytes());
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
}
// 8 bytes of SHA-256. This is a cache key, not a security boundary —
// it only has to make an accidental collision between two builds
// implausible, and it keeps the URL short enough to read in a log.
let version = hex::encode(&hasher.finalize()[..8]);
let raw = std::fs::read_to_string(dir.join("captcha-widget.js")).ok()?;
let source = raw.replace(ASSET_BASE_PLACEHOLDER, &format!("{ASSET_MOUNT}/{version}"));
Some(Self { version, source })
}
/// Path this bundle's immutable assets are mounted at.
pub fn mount_path(&self) -> String {
format!("{ASSET_MOUNT}/{}", self.version)
}
/// Serve the entry point.
pub fn respond(&self) -> Response {
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, ENTRY_CACHE_CONTROL),
],
self.source.clone(),
)
.into_response()
}
}
/// Fallback when no bundle could be loaded, so the route still answers
/// predictably instead of 404-ing as if the URL were wrong.
pub async fn unavailable() -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
"widget assets unavailable; check STATIC_DIR",
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
/// The repo's own `static/` is the bundle every deployment ships, so a
/// missing or renamed asset should fail here rather than at boot.
#[test]
fn loads_the_repo_bundle() {
let bundle = WidgetBundle::load("static").expect("static/ bundle should load");
assert_eq!(bundle.version.len(), 16, "8 bytes hex-encoded");
assert!(bundle.version.chars().all(|c| c.is_ascii_hexdigit()));
}
/// The whole scheme rests on the entry point no longer containing the
/// placeholder: if substitution silently stopped matching, the widget
/// would fall back to unversioned `/static` paths and the skew window
/// would reopen without anything failing loudly.
#[test]
fn entry_point_has_the_asset_base_substituted() {
let bundle = WidgetBundle::load("static").expect("static/ bundle should load");
assert!(
!bundle.source.contains(ASSET_BASE_PLACEHOLDER),
"placeholder must be rewritten in the served entry point"
);
assert!(bundle.source.contains(&bundle.mount_path()));
}
/// Content-addressing is only useful if it is stable across runs — an
/// unstable hash would evict every cache on every restart.
#[test]
fn version_is_deterministic() {
let a = WidgetBundle::load("static").expect("bundle");
let b = WidgetBundle::load("static").expect("bundle");
assert_eq!(a.version, b.version);
}
#[test]
fn missing_static_dir_yields_no_bundle() {
assert!(WidgetBundle::load("static/does-not-exist").is_none());
}
}