feat(bundled-binary): include /static-dist into binary
This commit is contained in:
Generated
+180
-12
@@ -29,6 +29,12 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler32"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adobe-cmap-parser"
|
name = "adobe-cmap-parser"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -160,7 +166,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -171,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1172,6 +1178,16 @@ dependencies = [
|
|||||||
"tinyvec",
|
"tinyvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bstr"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -1962,6 +1978,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.2.1"
|
version = "6.2.1"
|
||||||
@@ -2267,7 +2289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2884,6 +2906,19 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
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]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -3285,7 +3320,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.4",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3507,6 +3542,39 @@ dependencies = [
|
|||||||
"quick-error",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -3603,7 +3671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3802,6 +3870,30 @@ version = "0.2.186"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
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]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4278,6 +4370,15 @@ dependencies = [
|
|||||||
"rawpointer",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -4318,7 +4419,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4597,6 +4698,7 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"rayon",
|
"rayon",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -5006,6 +5108,28 @@ dependencies = [
|
|||||||
"toml_edit",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-utils"
|
name = "proc-macro-utils"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
@@ -5140,7 +5264,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls 0.23.40",
|
"rustls 0.23.40",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.4",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5177,7 +5301,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.4",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
@@ -5558,6 +5682,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rmp"
|
name = "rmp"
|
||||||
version = "0.8.15"
|
version = "0.8.15"
|
||||||
@@ -5597,6 +5727,44 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -5651,7 +5819,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6174,7 +6342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6686,7 +6854,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8154,7 +8322,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+25
@@ -47,6 +47,12 @@ futures = "0.3.32"
|
|||||||
async-stream = "0.3.6"
|
async-stream = "0.3.6"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
mime_guess = "2.0.5"
|
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"] }
|
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
arc-swap = "1.9"
|
arc-swap = "1.9"
|
||||||
@@ -174,6 +180,25 @@ bench = []
|
|||||||
# from `test_utils` for the same reason `load_seed_bin` is — enabling
|
# 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` on the CLI must not perturb the oxicloud dependency graph.
|
||||||
dev_tools = []
|
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]
|
[dev-dependencies]
|
||||||
criterion = "0.5"
|
criterion = "0.5"
|
||||||
|
|||||||
@@ -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`).
|
//! 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
|
//! The frontend is built by Vite into `static-dist/` at the repo root and
|
||||||
//! `static-dist/` and served directly by the web layer (`interfaces::web`).
|
//! 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::env;
|
||||||
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
git_status();
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
@@ -15,23 +15,105 @@ use tower_http::compression::CompressionLayer;
|
|||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
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
|
/// Returned by [`resolve_static_source`]; matched at each of the four
|
||||||
/// static path, falling back to the configured path itself — the container ships
|
/// consumer sites (SPA `ServeDir`, `_app/immutable` `ServeDir`,
|
||||||
/// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there
|
/// CSP inline-script scan, and the locale-loader picker in `main.rs`).
|
||||||
/// the fallback is what serves. Shared with the CSP layer in `main.rs` so the
|
#[derive(Debug, Clone)]
|
||||||
/// inline-script hashes are computed from exactly the bytes that get served.
|
pub enum StaticSource {
|
||||||
pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
|
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
|
let dist = config
|
||||||
.static_path
|
.static_path
|
||||||
.parent()
|
.parent()
|
||||||
.unwrap_or(Path::new("."))
|
.unwrap_or(Path::new("."))
|
||||||
.join("static-dist");
|
.join("static-dist");
|
||||||
if dist.exists() {
|
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.
|
/// 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;
|
/// Caching: content-hashed assets under `/_app/immutable` are cached forever;
|
||||||
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
|
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
|
||||||
/// can't leave a stale app pinned in browsers.
|
/// can't leave a stale app pinned in browsers.
|
||||||
pub fn create_web_routes(app_state: Arc<AppState>) -> Router<Arc<AppState>> {
|
pub fn create_web_routes(
|
||||||
let config = AppConfig::from_env();
|
app_state: Arc<AppState>,
|
||||||
let static_path = resolve_static_path(&config);
|
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.
|
// Build the router — two shapes depending on `StaticSource`, but both
|
||||||
//
|
// wear the SAME outer layers below (compression fallback, no-cache
|
||||||
// `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz`
|
// default for the shell, OIDC login short-circuit). Keeping the
|
||||||
// (frontend/scripts/precompress.mjs runs at build time), serve those
|
// layers common means the filesystem and embedded paths behave
|
||||||
// bytes directly with the right Content-Encoding instead of re-running
|
// identically at the wire boundary.
|
||||||
// Brotli over the same immutable bundle on EVERY request — the
|
let inner = match source {
|
||||||
// `CompressionLayer` below then skips the already-encoded response and
|
StaticSource::Filesystem(static_path) => web_routes_filesystem(&static_path),
|
||||||
// remains only the fallback for assets without a precompressed sibling
|
#[cfg(feature = "bundled-assets")]
|
||||||
// (benches/STATIC-PRECOMPRESSED.md).
|
StaticSource::Embedded => web_routes_embedded(),
|
||||||
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).
|
inner
|
||||||
let app_immutable = ServeDir::new(static_path.join("_app").join("immutable"))
|
// Fallback compression for assets without a precompressed sibling
|
||||||
.precompressed_br()
|
// (filesystem) or for embedded assets that were compressed at
|
||||||
.precompressed_gzip();
|
// compile time and decompressed on read (bundled). Quality 4,
|
||||||
|
// NOT the default: the default maps to Brotli q11 — ~1.3 s of
|
||||||
Router::new()
|
// CPU per 700 KiB bundle per request (benches/STATIC-PRECOMPRESSED.md;
|
||||||
.nest_service(
|
// the .br siblings on the filesystem path carry the real q11
|
||||||
"/_app/immutable",
|
// bytes, paid once at build time).
|
||||||
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).
|
|
||||||
.layer(
|
.layer(
|
||||||
CompressionLayer::new()
|
CompressionLayer::new()
|
||||||
.quality(tower_http::CompressionLevel::Precise(4))
|
.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
|
/// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is
|
||||||
/// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`).
|
/// 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
|
/// web worker from a blob URL; `'self'` covers same-origin workers like the
|
||||||
/// delta-upload worker.
|
/// delta-upload worker.
|
||||||
pub fn content_security_policy(config: &AppConfig) -> String {
|
pub fn content_security_policy(config: &AppConfig) -> String {
|
||||||
let static_path = resolve_static_path(config);
|
let source = resolve_static_source(config);
|
||||||
let hashes = inline_script_csp_hashes(&static_path);
|
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() {
|
if hashes.is_empty() {
|
||||||
tracing::warn!(
|
match &source {
|
||||||
static_path = %static_path.display(),
|
StaticSource::Filesystem(p) => {
|
||||||
"CSP: no inline <script> hashes computed — if the SPA shell ships \
|
tracing::warn!(
|
||||||
inline scripts they will be blocked by script-src 'self'. Check the \
|
static_path = %p.display(),
|
||||||
static asset path (OXICLOUD_STATIC_PATH)."
|
"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
|
// `'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()
|
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.
|
/// The CSP `'sha256-<base64>'` source expression for one inline script body.
|
||||||
fn csp_hash(script: &str) -> String {
|
fn csp_hash(script: &str) -> String {
|
||||||
let digest = Sha256::digest(script.as_bytes());
|
let digest = Sha256::digest(script.as_bytes());
|
||||||
|
|||||||
+94
-10
@@ -58,7 +58,7 @@ use common::di::AppServiceFactory;
|
|||||||
use infrastructure::db::create_database_pools;
|
use infrastructure::db::create_database_pools;
|
||||||
use interfaces::{
|
use interfaces::{
|
||||||
create_api_routes, create_health_routes, create_public_api_routes,
|
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> {
|
fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> {
|
||||||
@@ -350,6 +350,92 @@ fn build_runtime() -> std::io::Result<tokio::runtime::Runtime> {
|
|||||||
.build()
|
.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 entrypoint, driven by the runtime built in [`main`].
|
||||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initialize tracing.
|
// 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
|
// /app/static). Fail-fast if the path is missing rather than silently
|
||||||
// creating an empty directory and limping along with a "translation missing"
|
// creating an empty directory and limping along with a "translation missing"
|
||||||
// error on every request later.
|
// error on every request later.
|
||||||
let locales_path = {
|
// Resolve the static-assets source ONCE at boot — the resolution
|
||||||
let served = resolve_static_path(&config).join("locales");
|
// logs a single line describing which path was chosen. Reused
|
||||||
if served.is_dir() {
|
// downstream for both the locale loader (below) and the web router
|
||||||
served
|
// (`create_web_routes`), so neither has to re-parse env or re-log.
|
||||||
} else {
|
let static_source = resolve_static_source(&config);
|
||||||
std::path::PathBuf::from("frontend/static/locales")
|
let locales_path = resolve_locales_path(&static_source);
|
||||||
}
|
|
||||||
};
|
|
||||||
if !locales_path.is_dir() {
|
if !locales_path.is_dir() {
|
||||||
panic!(
|
panic!(
|
||||||
"FATAL: locales directory not found at {}. \
|
"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 api_routes = create_api_routes(&app_state);
|
||||||
let public_api_routes = create_public_api_routes(&app_state);
|
let public_api_routes = create_public_api_routes(&app_state);
|
||||||
let health_routes = create_health_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;
|
let mut app;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user