//! The static assets compiled into the binary.
//!
//! The logo and the stylesheet ship inside the executable rather than in a
//! `static/` directory next to it, so serving them does not depend on the
//! process' working directory or on a deployment copying the directory along.
//!
//! # Caching
//!
//! The bytes are compiled in, so they cannot change while the process lives:
//! a redeploy with different assets is the only event that can make a copy
//! already held by a browser wrong. Responses therefore say how long that copy
//! may be reused without asking - no request at all while it is fresh - and
//! carry a strong `ETag` so that once it expires the browser revalidates with
//! `If-None-Match` and gets a body free `304` rather than the whole file
//! again. The validator is the hash of the content rather than anything about
//! the process, so restarting the same binary, or shipping one that left an
//! asset alone, keeps every cached copy valid.
use std::collections::HashMap;
use std::sync::LazyLock;
use actix_web::http::header::{self, HeaderValue};
use actix_web::{HttpRequest, HttpResponse, get, web};
use rust_embed::RustEmbed;
use sha2::{Digest, Sha256};
use tracing::info_span;
/// Everything under `frogue/static`, embedded at compile time. Paths are
/// relative to that folder, so a file written there as `style.css` is served
/// at `/static/style.css`.
///
/// `debug-embed` keeps a debug build at it too. Without it rust-embed reads
/// the folder from disk during development, which reintroduces exactly the
/// dependency on where the source tree sits that this module exists to remove.
#[derive(RustEmbed)]
#[folder = "static"]
struct StaticAssets;
/// How long a browser may keep using an embedded asset without asking again.
///
/// Nothing but a redeploy can change an asset, so this bounds exactly one
/// thing: how long a returning visitor can keep seeing the previous
/// stylesheet after a new binary shipped. Ten minutes buys a largely
/// request-free session while keeping that window short.
const CACHE_CONTROL: &str = "public, max-age=600";
/// Every embedded asset's [`etag_for`] of its bytes, keyed by the path it is
/// served at, hashed once by [`hash_assets`].
///
/// Content derived on purpose: the validator follows the bytes, so the same
/// binary restarted - or a new one that did not touch an asset - hands out
/// the same value and browsers keep the copies they already hold. A boot
/// timestamp or a random value would throw those away on every restart.
static ETAGS: LazyLock<HashMap<String, String>> = LazyLock::new(hash_assets);
/// Hashes every embedded asset now, so the request path only ever compares.
///
/// Called from [`crate::run`] before the server binds, while the telemetry
/// subscriber is already up: [`hash_assets`] is where the hashing spans are
/// emitted, and forcing the table here lands them in the startup trace rather
/// than in whichever request happened to arrive first.
pub(crate) fn warm_up() {
LazyLock::force(&ETAGS);
}
/// The hashing pass behind [`ETAGS`], one child span per asset underneath a
/// span for the pass as a whole.
///
/// This runs once per process - the embedded bytes cannot change under it -
/// so what it emits reads as a startup trace: which assets the binary ships,
/// how many bytes each is, and the validator each produced.
fn hash_assets() -> HashMap<String, String> {
let span = info_span!("static_files.hash", asset.count = tracing::field::Empty);
let _pass = span.enter();
let mut etags = HashMap::new();
let mut count = 0;
for path in StaticAssets::iter() {
// `iter` and `get` read the same embedded table, so this cannot miss;
// skipping rather than assuming keeps a future change here from
// panicking a request that trusted the table.
let Some(asset) = StaticAssets::get(&path) else {
continue;
};
let asset_span = info_span!(
"static_files.hash_asset",
asset.path = %path,
asset.bytes = asset.data.len(),
asset.etag = tracing::field::Empty,
);
let etag = {
let _asset = asset_span.enter();
etag_for(&asset.data)
};
asset_span.record("asset.etag", etag.as_str());
count += 1;
etags.insert(path.into_owned(), etag);
}
span.record("asset.count", count);
etags
}
/// The strong validator for `bytes`: their SHA-256, hex encoded and quoted
/// the way an entity-tag is spelled.
fn etag_for(bytes: &[u8]) -> String {
format!("\"{}\"", hex::encode(Sha256::digest(bytes)))
}
/// Whether `If-None-Match` names `etag`.
///
/// Weak comparison, as RFC 9110 requires of `If-None-Match`: the question is
/// only whether the bytes are known to differ, so a weak `W/"..."` answers
/// the same as the strong form served here. Anything unparseable is treated
/// as no match, which costs a body rather than correctness.
fn matches_if_none_match(value: Option<&HeaderValue>, etag: &str) -> bool {
let Some(value) = value.and_then(|value| value.to_str().ok()) else {
return false;
};
value.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*"
|| candidate.strip_prefix("W/").unwrap_or(candidate)
== etag.strip_prefix("W/").unwrap_or(etag)
})
}
/// Serves one embedded asset, content type guessed from its name, or a `404`
/// when there is nothing under that name.
///
/// The lookup goes through the embedded table, which holds only the paths that
/// were compiled in, so a request for anything else - including one containing
/// `..` - misses rather than reaching a file on disk.
///
/// A browser that already holds a copy revalidates before it downloads: a
/// matching `If-None-Match` answers `304` with no body, so extending
/// [`CACHE_CONTROL`] costs nothing to keep extending.
#[get("/static/{path:.*}")]
pub(crate) async fn serve(path: web::Path<String>, request: HttpRequest) -> HttpResponse {
let Some(asset) = StaticAssets::get(&path) else {
return HttpResponse::NotFound().finish();
};
// The table is built from the same embedded tree this lookup went
// through, so a miss would mean hashing has not run yet; hashing here
// keeps that a non-event rather than a panic on an invariant.
let etag = ETAGS
.get(path.as_str())
.cloned()
.unwrap_or_else(|| etag_for(&asset.data));
if matches_if_none_match(request.headers().get(header::IF_NONE_MATCH), &etag) {
return HttpResponse::NotModified()
.insert_header((header::ETAG, etag))
.insert_header((header::CACHE_CONTROL, CACHE_CONTROL))
.finish();
}
HttpResponse::Ok()
.insert_header((header::CACHE_CONTROL, CACHE_CONTROL))
.insert_header((header::ETAG, etag))
// `Compress` (see `lib.rs`) encodes this response according to what
// the request asked to receive, so a cache further along must key on
// `Accept-Encoding` as well or it could hand a gzipped body to a
// client that never asked for one. The `ETag` names the asset rather
// than the encoding, hence this rather than a second validator.
.insert_header((header::VARY, "Accept-Encoding"))
.insert_header((header::X_CONTENT_TYPE_OPTIONS, "nosniff"))
.content_type(asset.metadata.mimetype())
.body(asset.data.into_owned())
}