feat(bundled-binary): include /static-dist into binary

This commit is contained in:
Edouard Vanbelle
2026-08-29 01:27:32 +02:00
parent 390aa31443
commit e562a3de30
6 changed files with 731 additions and 77 deletions
+163
View File
@@ -0,0 +1,163 @@
//! Compile-time-embedded static assets — the `bundled-assets` feature.
//!
//! When the feature is on, the SvelteKit build output at `static-dist/`
//! (repo root, per `frontend/svelte.config.js`'s `adapter-static`) is
//! baked into the binary via `rust-embed` at compile time. The two axum
//! handlers below (`serve_root` for the SPA + fallback, `serve_immutable`
//! for the `_app/immutable` cache-forever subtree) parallel the two
//! `ServeDir` instances the filesystem path uses in `super::mod`.
//!
//! **Precedence** — this module is only invoked when
//! `resolve_static_source(config)` returns `StaticSource::Embedded`. If
//! `OXICLOUD_STATIC_PATH` (or the default `./static/static-dist/`)
//! points at a real directory, the filesystem `ServeDir` path is used
//! instead — ops can still override embedded bytes for locale patches
//! or theming without a full rebuild.
//!
//! **Compression** — `rust-embed`'s `compression` feature stores each
//! embedded file deflate-compressed. Lazy decompression on first access
//! keeps the binary small (~4-5 MB for the current corpus) and the
//! runtime cost negligible: after warmup every file is cached. Response
//! compression is handled by `CompressionLayer` in the parent module —
//! same wire behaviour as the filesystem path for `Content-Encoding:
//! br|gzip|identity` clients.
//!
//! **Vite's precompressed siblings** (`.br` / `.gz`) are excluded from
//! the embed via the `#[exclude]` attributes below — they'd be dead
//! weight because the response compression on the wire already handles
//! this negotiation.
use axum::body::Body;
use axum::extract::{Path, Request};
use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
/// SvelteKit SPA build output, baked into the binary at compile time.
///
/// The `#[folder]` path is relative to `Cargo.toml` (repo root), which
/// matches SvelteKit's `adapter-static` output in
/// `frontend/svelte.config.js` (`pages: '../static-dist'`).
#[derive(rust_embed::RustEmbed)]
#[folder = "static-dist/"]
// Default (no `include` attr) = include everything recursively. An
// explicit `include = "*"` was WRONG — the `*` glob is single-segment
// only, so `locales/*.json`, `_app/immutable/**`, and every other
// subdirectory got excluded and the boot-time `extract_embedded_locales`
// found 0 files (2026-08-28 bug fix).
//
// Vite's precompressed siblings — we serve raw and let the axum
// `CompressionLayer` on the wire negotiate br/gzip. Doubling storage
// would balloon the embed by ~50%.
#[exclude = "**/*.br"]
#[exclude = "**/*.gz"]
pub struct EmbeddedAssets;
/// Serve any embedded asset by request path, falling back to the SPA
/// shell (`index.html`) for unmatched client routes.
///
/// Mirror of the `spa` `ServeDir` in `super::create_web_routes` — same
/// fallback semantics so deep links like `/files/<id>` boot the SvelteKit
/// router. `Cache-Control` for the shell itself is left to the outer
/// layer in the parent module (`no-cache` so a deploy can't leave a
/// stale app pinned in browsers); assets carrying no cache header here
/// pick up the parent's default the same way filesystem-served assets do.
///
/// Wired as axum's `fallback` in `super::web_routes_embedded`, which
/// means there is NO route pattern to capture from — a `Path` extractor
/// would fail at runtime with "Wrong number of path arguments for
/// `Path`. Expected 1 but got 0." (real bug hit 2026-08-28). Pull the
/// URI path off the `Request` directly instead.
pub async fn serve_root(req: Request) -> Response {
let path = req.uri().path().trim_start_matches('/');
if path.is_empty() {
return spa_shell_response();
}
match EmbeddedAssets::get(path) {
Some(file) => asset_response(path, file.data),
None => spa_shell_response(),
}
}
/// Root path (no trailing capture) — always the SPA shell.
///
/// axum routes `/` separately from `/*path`, so this handles the
/// bare-slash case that `serve_root` never sees.
pub async fn serve_root_index() -> Response {
spa_shell_response()
}
/// Serve an asset under the `/_app/immutable/*` prefix. The nested route
/// registration in `super::create_web_routes` already strips the
/// `/_app/immutable/` prefix from the captured path, so we look the
/// stripped path up with the prefix re-attached before hitting the embed.
///
/// Cache-Control (`public, max-age=31536000, immutable`) is applied by
/// the outer `SetResponseHeaderLayer::overriding` in the parent module,
/// same as the filesystem path — this handler just returns bytes + MIME.
pub async fn serve_immutable(Path(path): Path<String>) -> Response {
let full = format!("_app/immutable/{}", path.trim_start_matches('/'));
match EmbeddedAssets::get(&full) {
Some(file) => asset_response(&path, file.data),
None => (StatusCode::NOT_FOUND, "Not Found").into_response(),
}
}
fn asset_response(path: &str, bytes: std::borrow::Cow<'static, [u8]>) -> Response {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let mut resp = Response::new(Body::from(bytes.into_owned()));
resp.headers_mut().insert(
CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static(
"application/octet-stream",
)),
);
resp
}
fn spa_shell_response() -> Response {
match EmbeddedAssets::get("index.html") {
Some(shell) => {
let mut resp = Response::new(Body::from(shell.data.into_owned()));
resp.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
// Belt: the parent module also stamps this on unset,
// but stamp it here too so the shell never accidentally
// ends up cacheable in front of a deploy.
resp.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
resp
}
None => {
// This means `static-dist/` was empty at build time — the
// build.rs guard should have prevented us from ever getting
// here. Surface as 500 rather than pretending the SPA works.
(
StatusCode::INTERNAL_SERVER_ERROR,
"SPA shell missing from embedded assets; rebuild with an up-to-date static-dist/",
)
.into_response()
}
}
}
/// Iterate over the embedded `.html` files at the root of `static-dist/`
/// so the CSP inline-script scanner can hash them without a filesystem
/// read. Mirrors what `std::fs::read_dir(static_path)` yields on the
/// filesystem path, filtered to `.html` at the top level.
pub fn embedded_html_shells() -> Vec<(String, std::borrow::Cow<'static, [u8]>)> {
EmbeddedAssets::iter()
.filter(|p| {
// Root-level `.html` only — SvelteKit emits `index.html` at
// the root and everything else under `_app/`. Nested `.html`
// (e.g. sourcemap tooling artefacts) doesn't inline-script,
// so skip.
let s: &str = p.as_ref();
s.ends_with(".html") && !s.contains('/')
})
.filter_map(|p| {
let name = p.to_string();
EmbeddedAssets::get(&name).map(|f| (name, f.data))
})
.collect()
}
+217 -52
View File
@@ -15,23 +15,105 @@ use tower_http::compression::CompressionLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::set_header::SetResponseHeaderLayer;
/// Resolve the directory the SPA is actually served from.
#[cfg(feature = "bundled-assets")]
pub mod embedded;
/// Where the SPA + immutable assets are served from — filesystem
/// (default and also the fallback on bundled builds when
/// `OXICLOUD_STATIC_PATH` points at real files) or the compile-time
/// embedded corpus (bundled-assets feature only).
///
/// Prefers the Vite build output (`static-dist/`) sitting next to the configured
/// static path, falling back to the configured path itself — the container ships
/// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there
/// the fallback is what serves. Shared with the CSP layer in `main.rs` so the
/// inline-script hashes are computed from exactly the bytes that get served.
pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
/// Returned by [`resolve_static_source`]; matched at each of the four
/// consumer sites (SPA `ServeDir`, `_app/immutable` `ServeDir`,
/// CSP inline-script scan, and the locale-loader picker in `main.rs`).
#[derive(Debug, Clone)]
pub enum StaticSource {
Filesystem(PathBuf),
/// Serve from the `EmbeddedAssets` corpus in the [`embedded`] module.
/// Only reachable under `--features bundled-assets` — the variant is
/// cfg-gated so match arms in non-bundled builds stay exhaustive on
/// a single variant, giving zero runtime cost.
#[cfg(feature = "bundled-assets")]
Embedded,
}
/// Resolve where static assets come from.
///
/// Order of precedence (highest first):
/// 1. `<OXICLOUD_STATIC_PATH>/../static-dist/` when it exists — matches
/// the SvelteKit adapter-static output at the repo root.
/// 2. `OXICLOUD_STATIC_PATH` itself when it exists — Docker image path
/// (assets copied straight to `/app/static/`).
/// 3. Bundled-assets fallback (only when the feature is on) — the
/// compile-time embedded corpus.
/// 4. Non-bundled fallback — return the configured path anyway, letting
/// downstream `ServeDir` fail predictably at request time.
///
/// Rule (2) exists so ops running a bundled binary can still point
/// `OXICLOUD_STATIC_PATH` at a live directory (locale patch, theme
/// override) and see it win over the embedded copy without a rebuild.
pub fn resolve_static_source(config: &AppConfig) -> StaticSource {
let dist = config
.static_path
.parent()
.unwrap_or(Path::new("."))
.join("static-dist");
if dist.exists() {
return dist;
tracing::info!(
source = %dist.display(),
"static-assets: serving from filesystem (Vite build output at <static>/../static-dist/)"
);
return StaticSource::Filesystem(dist);
}
if config.static_path.exists() {
tracing::info!(
source = %config.static_path.display(),
"static-assets: serving from filesystem (OXICLOUD_STATIC_PATH)"
);
return StaticSource::Filesystem(config.static_path.clone());
}
#[cfg(feature = "bundled-assets")]
{
tracing::info!(
configured_static_path = %config.static_path.display(),
"static-assets: no filesystem source found, serving embedded corpus \
(bundled-assets feature). Set OXICLOUD_STATIC_PATH to override with a \
live directory."
);
StaticSource::Embedded
}
#[cfg(not(feature = "bundled-assets"))]
{
// Non-bundled build with no on-disk source. Return the configured
// path anyway — downstream `ServeDir` will fail predictably at
// request time. A separate boot-time warning wouldn't help; the
// real fix is to build the SPA or set OXICLOUD_STATIC_PATH.
StaticSource::Filesystem(config.static_path.clone())
}
}
/// Backwards-compat helper: resolve to a `PathBuf` directly.
///
/// Preserved for callers that predate the `StaticSource` enum. Only
/// callable in configurations where a filesystem path exists — a
/// bundled build whose `resolve_static_source` returned `Embedded`
/// would panic here, so new code should always match on
/// `resolve_static_source(...)` instead.
pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
match resolve_static_source(config) {
StaticSource::Filesystem(p) => p,
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => {
// Every migrated caller matches on StaticSource directly;
// this branch means someone called the legacy helper from
// a bundled build. Fix the caller, not the shim.
panic!(
"resolve_static_path() called on a bundled build with no filesystem \
assets — migrate the caller to resolve_static_source() and match on \
StaticSource::Embedded"
)
}
}
config.static_path.clone()
}
/// Serves the SvelteKit single-page app.
@@ -44,43 +126,36 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
/// Caching: content-hashed assets under `/_app/immutable` are cached forever;
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
/// can't leave a stale app pinned in browsers.
pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> {
let config = AppConfig::from_env();
let static_path = resolve_static_path(&config);
pub fn create_web_routes(
app_state: Arc<AppState>,
source: StaticSource,
) -> Router<Arc<AppState>> {
// `source` is resolved ONCE at boot in `main.rs::run()` and passed
// in — see the sequence there. Previously this fn called
// `AppConfig::from_env()` + `resolve_static_source(&config)` itself
// (duplicating the env parse + storage-summary log). Threading the
// resolved value through as an arg keeps this fn pure and eliminates
// both duplicates in the boot log (bug fixed 2026-08-28).
// SPA fallback: serve the file if it exists, else the app shell.
//
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz`
// (frontend/scripts/precompress.mjs runs at build time), serve those
// bytes directly with the right Content-Encoding instead of re-running
// Brotli over the same immutable bundle on EVERY request — the
// `CompressionLayer` below then skips the already-encoded response and
// remains only the fallback for assets without a precompressed sibling
// (benches/STATIC-PRECOMPRESSED.md).
let spa = ServeDir::new(&static_path)
.precompressed_br()
.precompressed_gzip()
.fallback(ServeFile::new(static_path.join("index.html")));
// Build the router — two shapes depending on `StaticSource`, but both
// wear the SAME outer layers below (compression fallback, no-cache
// default for the shell, OIDC login short-circuit). Keeping the
// layers common means the filesystem and embedded paths behave
// identically at the wire boundary.
let inner = match source {
StaticSource::Filesystem(static_path) => web_routes_filesystem(&static_path),
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => web_routes_embedded(),
};
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable).
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"))
.precompressed_br()
.precompressed_gzip();
Router::new()
.nest_service(
"/_app/immutable",
get_service(app_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.fallback_service(spa)
// Fallback compression for assets without a precompressed sibling.
// Quality 4, NOT the default: the default maps to Brotli q11 —
// ~1.3 s of CPU per 700 KiB bundle per request (measured in
// benches/STATIC-PRECOMPRESSED.md; the .br siblings above carry the
// real q11 bytes, paid once at build time).
inner
// Fallback compression for assets without a precompressed sibling
// (filesystem) or for embedded assets that were compressed at
// compile time and decompressed on read (bundled). Quality 4,
// NOT the default: the default maps to Brotli q11 — ~1.3 s of
// CPU per 700 KiB bundle per request (benches/STATIC-PRECOMPRESSED.md;
// the .br siblings on the filesystem path carry the real q11
// bytes, paid once at build time).
.layer(
CompressionLayer::new()
.quality(tower_http::CompressionLevel::Precise(4))
@@ -105,6 +180,58 @@ pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> {
))
}
/// Filesystem-served SPA — the historical shape. Two `ServeDir` instances
/// with tower-http's `precompressed_br().precompressed_gzip()` picking up
/// Vite's precompressed siblings when present.
fn web_routes_filesystem(static_path: &Path) -> Router<Arc<AppState>> {
// SPA fallback: serve the file if it exists, else the app shell.
//
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz`
// (frontend/scripts/precompress.mjs runs at build time), serve those
// bytes directly with the right Content-Encoding instead of re-running
// Brotli over the same immutable bundle on EVERY request — the
// `CompressionLayer` in `create_web_routes` then skips the already-encoded
// response and remains only the fallback for assets without a
// precompressed sibling (benches/STATIC-PRECOMPRESSED.md).
let spa = ServeDir::new(static_path)
.precompressed_br()
.precompressed_gzip()
.fallback(ServeFile::new(static_path.join("index.html")));
// Hashed, immutable assets (SvelteKit emits these under /_app/immutable).
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"))
.precompressed_br()
.precompressed_gzip();
Router::new()
.nest_service(
"/_app/immutable",
get_service(app_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.fallback_service(spa)
}
/// Embedded-assets SPA — mirror of `web_routes_filesystem` using the
/// [`embedded`] module's handlers instead of `ServeDir`. Same URL shape,
/// same cache-header layers, same SPA-shell fallback semantics.
#[cfg(feature = "bundled-assets")]
fn web_routes_embedded() -> Router<Arc<AppState>> {
use axum::routing::get;
Router::new()
.route(
"/_app/immutable/{*path}",
get(embedded::serve_immutable).layer(SetResponseHeaderLayer::overriding(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
)),
)
.route("/", get(embedded::serve_root_index))
.fallback(get(embedded::serve_root))
}
/// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is
/// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`).
///
@@ -161,15 +288,31 @@ async fn oidc_standalone_login_redirect(
/// web worker from a blob URL; `'self'` covers same-origin workers like the
/// delta-upload worker.
pub fn content_security_policy(config: &AppConfig) -> String {
let static_path = resolve_static_path(config);
let hashes = inline_script_csp_hashes(&static_path);
let source = resolve_static_source(config);
let hashes = match &source {
StaticSource::Filesystem(p) => inline_script_csp_hashes(p),
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => inline_script_csp_hashes_embedded(),
};
if hashes.is_empty() {
tracing::warn!(
static_path = %static_path.display(),
"CSP: no inline <script> hashes computed — if the SPA shell ships \
inline scripts they will be blocked by script-src 'self'. Check the \
static asset path (OXICLOUD_STATIC_PATH)."
);
match &source {
StaticSource::Filesystem(p) => {
tracing::warn!(
static_path = %p.display(),
"CSP: no inline <script> hashes computed — if the SPA shell ships \
inline scripts they will be blocked by script-src 'self'. Check the \
static asset path (OXICLOUD_STATIC_PATH)."
);
}
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => {
tracing::warn!(
"CSP: no inline <script> hashes computed from embedded corpus — \
the SPA shell may boot with a blocked script-src. Rebuild with \
an up-to-date static-dist/."
);
}
}
}
// `'wasm-unsafe-eval'` is required for WebAssembly compilation/instantiation
@@ -232,6 +375,28 @@ fn inline_script_csp_hashes(static_path: &Path) -> Vec<String> {
hashes.into_iter().collect()
}
/// Embedded-corpus twin of [`inline_script_csp_hashes`].
///
/// Same arithmetic — iterate root-level `.html` shells, extract every
/// inline `<script>`, hash each — but pulls bytes from
/// [`embedded::EmbeddedAssets`] instead of the filesystem. The two
/// functions produce identical output for the same source tree, so
/// `content_security_policy` can pick either without callers seeing a
/// difference.
#[cfg(feature = "bundled-assets")]
fn inline_script_csp_hashes_embedded() -> Vec<String> {
let mut hashes = BTreeSet::new();
for (_name, bytes) in embedded::embedded_html_shells() {
let Ok(html) = std::str::from_utf8(&bytes) else {
continue;
};
for script in inline_scripts(html) {
hashes.insert(csp_hash(script));
}
}
hashes.into_iter().collect()
}
/// The CSP `'sha256-<base64>'` source expression for one inline script body.
fn csp_hash(script: &str) -> String {
let digest = Sha256::digest(script.as_bytes());