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
Generated
+180 -12
View File
@@ -29,6 +29,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adler32"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
[[package]]
name = "adobe-cmap-parser"
version = "0.4.1"
@@ -160,7 +166,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -171,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1172,6 +1178,16 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -1962,6 +1978,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "dary_heap"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -2267,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2884,6 +2906,19 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -3285,7 +3320,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.4",
"tokio",
"tower-service",
"tracing",
@@ -3507,6 +3542,39 @@ dependencies = [
"quick-error",
]
[[package]]
name = "include-flate"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
dependencies = [
"include-flate-codegen",
"include-flate-compress",
]
[[package]]
name = "include-flate-codegen"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
dependencies = [
"include-flate-compress",
"proc-macro-error3",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "include-flate-compress"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
dependencies = [
"libflate",
"zstd",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3603,7 +3671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3802,6 +3870,30 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libflate"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c"
dependencies = [
"adler32",
"crc32fast",
"dary_heap",
"libflate_lz77",
"no_std_io2",
]
[[package]]
name = "libflate_lz77"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
dependencies = [
"hashbrown 0.16.1",
"no_std_io2",
"rle-decode-fast",
]
[[package]]
name = "libloading"
version = "0.9.0"
@@ -4278,6 +4370,15 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -4318,7 +4419,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4597,6 +4698,7 @@ dependencies = [
"rand_core 0.6.4",
"rayon",
"reqwest",
"rust-embed",
"serde",
"serde_json",
"sha2 0.11.0",
@@ -5006,6 +5108,28 @@ dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-error-attr3"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error3"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50"
dependencies = [
"proc-macro-error-attr3",
"proc-macro2",
"quote",
"syn 3.0.2",
]
[[package]]
name = "proc-macro-utils"
version = "0.10.0"
@@ -5140,7 +5264,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
"socket2 0.5.10",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5177,7 +5301,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.4",
"tracing",
"windows-sys 0.60.2",
]
@@ -5558,6 +5682,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
[[package]]
name = "rmp"
version = "0.8.15"
@@ -5597,6 +5727,44 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"include-flate",
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.117",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"globset",
"include-flate",
"sha2 0.11.0",
"walkdir",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -5651,7 +5819,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6174,7 +6342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6686,7 +6854,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8154,7 +8322,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+25
View File
@@ -47,6 +47,12 @@ futures = "0.3.32"
async-stream = "0.3.6"
async-trait = "0.1.89"
mime_guess = "2.0.5"
# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature.
# Optional so default builds never pull it in. `compression` deflate-compresses
# each embedded file at compile time; the handler decompresses lazily on first
# read (cached per-file in a `OnceCell`). See src/interfaces/web/embedded.rs
# for the actual embed struct + handlers.
rust-embed = { version = "8", features = ["compression", "include-exclude"], optional = true }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
thiserror = "2.0.18"
arc-swap = "1.9"
@@ -174,6 +180,25 @@ bench = []
# from `test_utils` for the same reason `load_seed_bin` is — enabling
# `dev_tools` on the CLI must not perturb the oxicloud dependency graph.
dev_tools = []
# Bake the SvelteKit build output (`static-dist/` at repo root) into the
# binary at compile time via `rust-embed`. Opt-in and off by default — the
# regular `cargo build --release` still produces a filesystem-served
# binary (matching the current Docker image where assets are separate
# layers, and the `just dev` HMR loop where Vite serves live). Release
# tarballs (`docs/plan/bundled-binary.md` § 2) build with this flag to
# ship a single self-contained executable.
#
# Precedence rule preserved: even when this feature is on, if
# OXICLOUD_STATIC_PATH points at an existing directory, that wins over
# the embedded fallback — ops can override embedded assets for locale
# patches or theming without a rebuild.
#
# Build-time invariant: `cargo build --features bundled-assets` requires
# `static-dist/` at the repo root (SvelteKit adapter-static emits there
# — `frontend/svelte.config.js`'s `pages: '../static-dist'`). `build.rs`
# fails fast with a pointer to `(cd frontend && npm run build)` when the
# directory is missing.
bundled-assets = ["dep:rust-embed"]
[dev-dependencies]
criterion = "0.5"
+52 -3
View File
@@ -1,15 +1,64 @@
//! build.rs — injects git build metadata into the binary.
//! build.rs — injects git build metadata into the binary and, under the
//! optional `bundled-assets` feature, guards the compile-time embed
//! precondition.
//!
//! Exposes `GIT_HASH` and `GIT_BRANCH` (consumed via `env!()` in `main.rs`).
//! There is no Rust-side asset pipeline: the frontend is built by Vite into
//! `static-dist/` and served directly by the web layer (`interfaces::web`).
//! The frontend is built by Vite into `static-dist/` at the repo root and
//! served directly by the web layer (`interfaces::web`); when
//! `bundled-assets` is on, `src/interfaces/web/embedded.rs` bakes that
//! directory into the binary at compile time via `rust-embed`.
use std::env;
use std::path::Path;
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=build.rs");
git_status();
bundled_assets_guard();
}
// ═══════════════════════════════════════════════════════════════════════════════
// Bundled-assets precondition guard
//
// When `--features bundled-assets` is on, `rust-embed`'s `#[folder = "static-dist/"]`
// scans that directory at compile time and errors with a not-very-helpful
// "No such file or directory" if it's missing. Users hit this first when they
// try `cargo build --release --features bundled-assets` before running the
// frontend build — we intercept it here with a clear, actionable message.
//
// Also emits `cargo:rerun-if-changed=static-dist/` so a fresh frontend build
// re-triggers the embed step without needing `cargo clean` — matches what a
// dev on the bundled feature would expect after `just fe-build`.
// ═══════════════════════════════════════════════════════════════════════════════
fn bundled_assets_guard() {
if env::var("CARGO_FEATURE_BUNDLED_ASSETS").is_err() {
return;
}
println!("cargo:rerun-if-changed=static-dist");
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let dist = Path::new(&manifest_dir).join("static-dist");
let index = dist.join("index.html");
if !index.exists() {
// `cargo:warning=` prefixes surface these in the terminal even
// when cargo's default output is quiet; the panic below turns
// them into a compile-time error so the missing prerequisite
// can't slip past a distracted dev.
println!(
"cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root."
);
println!(
"cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)"
);
println!(
"cargo:warning=Or via the workspace shortcut: just fe-build"
);
panic!(
"build.rs: missing {}/index.html — see the cargo:warning lines above",
dist.display()
);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
+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());
+94 -10
View File
@@ -58,7 +58,7 @@ use common::di::AppServiceFactory;
use infrastructure::db::create_database_pools;
use interfaces::{
create_api_routes, create_health_routes, create_public_api_routes,
web::{create_web_routes, resolve_static_path},
web::{StaticSource, create_web_routes, resolve_static_source},
};
fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> {
@@ -350,6 +350,92 @@ fn build_runtime() -> std::io::Result<tokio::runtime::Runtime> {
.build()
}
/// Resolve where locale JSON files are read from at boot time.
///
/// Under the default (filesystem) build: return the same path the
/// filesystem `ServeDir` serves from, with a fallback to
/// `frontend/static/locales` for `just dev` checkouts where the SPA
/// build hasn't run.
///
/// Under `--features bundled-assets`, when `resolve_static_source`
/// returns `Embedded` (i.e. no filesystem override is present), extract
/// the embedded `locales/*.json` files to a boot-time tempdir and
/// return that path. Runtime code (`LocaleRegistry::discover`,
/// `FileSystemI18nService`) is unchanged: it still reads locale JSON
/// from a directory. The tempdir is process-scoped; `LocaleRegistry`
/// caches everything in-memory at boot, so the extracted files are
/// unused after the initial scan and can leak on abrupt process death
/// without affecting subsequent boots.
fn resolve_locales_path(source: &StaticSource) -> std::path::PathBuf {
match source {
StaticSource::Filesystem(path) => {
let served = path.join("locales");
if served.is_dir() {
served
} else {
std::path::PathBuf::from("frontend/static/locales")
}
}
#[cfg(feature = "bundled-assets")]
StaticSource::Embedded => extract_embedded_locales(),
}
}
/// Extract the embedded `locales/*.json` corpus to a boot-time tempdir
/// so the existing filesystem-based locale loader can consume it
/// unchanged. Called once at boot in the embedded-assets path.
///
/// Cost: ~50 ms for 16 JSON files totalling ~2.2 MB. The tempdir lives
/// under `std::env::temp_dir()` (respects `$TMPDIR`); no cleanup is
/// registered because `LocaleRegistry::discover` reads every file into
/// memory at boot, so the extracted copy is dead weight once boot
/// completes. On a graceful shutdown the tempdir persists until the
/// OS's tmpfs / cron reaper collects it; on abrupt kill likewise. Safe:
/// no secrets touch these files.
#[cfg(feature = "bundled-assets")]
fn extract_embedded_locales() -> std::path::PathBuf {
use interfaces::web::embedded::EmbeddedAssets;
let dir = std::env::temp_dir().join(format!("oxicloud-locales-{}", std::process::id()));
if let Err(e) = std::fs::create_dir_all(&dir) {
panic!(
"FATAL: failed to create embedded-locales staging dir at {}: {e}",
dir.display()
);
}
let mut count = 0usize;
for path in EmbeddedAssets::iter() {
let s: &str = path.as_ref();
// Root-level `locales/*.json` only. SvelteKit copies
// `frontend/static/locales/*.json` here at build time.
if !s.starts_with("locales/") || !s.ends_with(".json") {
continue;
}
let name = &s["locales/".len()..];
if name.contains('/') {
continue; // no nested subdirs today
}
let Some(file) = EmbeddedAssets::get(s) else {
continue;
};
let out = dir.join(name);
if let Err(e) = std::fs::write(&out, file.data.as_ref()) {
panic!(
"FATAL: failed to stage embedded locale {} at {}: {e}",
s,
out.display()
);
}
count += 1;
}
tracing::info!(
staging_dir = %dir.display(),
count,
"static-assets: staged {count} embedded locale file(s) for the boot-time \
LocaleRegistry scan (bundled-assets feature)."
);
dir
}
/// Async entrypoint, driven by the runtime built in [`main`].
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing.
@@ -500,14 +586,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
// /app/static). Fail-fast if the path is missing rather than silently
// creating an empty directory and limping along with a "translation missing"
// error on every request later.
let locales_path = {
let served = resolve_static_path(&config).join("locales");
if served.is_dir() {
served
} else {
std::path::PathBuf::from("frontend/static/locales")
}
};
// Resolve the static-assets source ONCE at boot — the resolution
// logs a single line describing which path was chosen. Reused
// downstream for both the locale loader (below) and the web router
// (`create_web_routes`), so neither has to re-parse env or re-log.
let static_source = resolve_static_source(&config);
let locales_path = resolve_locales_path(&static_source);
if !locales_path.is_dir() {
panic!(
"FATAL: locales directory not found at {}. \
@@ -532,7 +616,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let api_routes = create_api_routes(&app_state);
let public_api_routes = create_public_api_routes(&app_state);
let health_routes = create_health_routes(&app_state);
let web_routes = create_web_routes(app_state.clone());
let web_routes = create_web_routes(app_state.clone(), static_source);
let mut app;