From 9ebfef16e99b39c74bb6b6c7731088ce85ea2fff Mon Sep 17 00:00:00 2001 From: zijiren <84728412+zijiren233@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:23:27 +0800 Subject: [PATCH] feat(web): support runtime UI assets (#440) ## Summary - add a `web-ui-dynamic` feature that serves a mutable Web distribution without compile-time embedding - add `server.web_ui_directory` and `SYNCTV_SERVER_WEB_UI_DIRECTORY`, with per-request disk reads, SPA fallback, security headers, and path containment checks - keep `web-ui` release builds embedded while allowing the same production binary and Docker image to use a mounted runtime directory - configure `make dev-serve` to use `synctv-web-ui/dist` and document both modes ## Docker behavior CI continues to build release images with `web-ui`, so embedded assets remain the default. Mounting a distribution and setting `SYNCTV_SERVER_WEB_UI_DIRECTORY` makes that directory authoritative at runtime. ## Verification - `cargo fmt --all -- --check` - `git diff --check` - `cargo test -p synctv-api-http --features web-ui-dynamic http::web_ui::tests` - `cargo test -p synctv-api-http --features web-ui http::web_ui::tests` - `cargo check -p synctv --features web-ui-dynamic` - `cargo check -p synctv --features web-ui` --- .env.synctv.example | 3 + Cargo.lock | 1 + Makefile | 5 +- .../docs/configuration/server-and-runtime.mdx | 12 + .../en/configuration/server-and-runtime.mdx | 12 + .../en/reference/environment-variables.mdx | 2 + .../docs/reference/environment-variables.mdx | 1 + synctv-api-common/src/server_settings.rs | 2 + synctv-api-http/Cargo.toml | 2 + synctv-api-http/src/http/mod.rs | 10 +- synctv-api-http/src/http/tests.rs | 4 +- synctv-api-http/src/http/web_ui.rs | 322 +++++++++++++++--- synctv-api/Cargo.toml | 1 + synctv-web-ui/README.md | 27 +- synctv/Cargo.toml | 1 + synctv/src/app.rs | 1 + synctv/src/app_config/mod.rs | 2 + synctv/src/app_config/validation.rs | 20 ++ synctv/src/config_env.rs | 17 + synctv/src/resource_options.rs | 1 + synctv/tests/cluster_startup_failure_tests.rs | 2 + 21 files changed, 393 insertions(+), 55 deletions(-) diff --git a/.env.synctv.example b/.env.synctv.example index 3a3ff0f8..a14bc565 100644 --- a/.env.synctv.example +++ b/.env.synctv.example @@ -87,6 +87,9 @@ SYNCTV_DATA_DIR=/data SYNCTV_SERVER_HOST=0.0.0.0 SYNCTV_SERVER_PORT=8080 +# Optional runtime Web UI directory for development or external asset deployment. +# The server reads files on every request. Relative paths use the process working directory. +# SYNCTV_SERVER_WEB_UI_DIRECTORY=/app/synctv-web-ui/dist # Dedicated health listener used by the container healthcheck. It stays inside # the Docker network and is not published to the host by docker-compose.yml. diff --git a/Cargo.lock b/Cargo.lock index 06515bcf..379ab53c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7859,6 +7859,7 @@ dependencies = [ "synctv-proxy", "synctv-realtime", "synctv-web-ui", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/Makefile b/Makefile index 25fadab3..bac0813f 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ CARGO_BUILD_ARGS ?= $(CARGO_JOBS_ARGS) $(CARGO_LOCKED) CARGO_WORKSPACE_BUILD_ARGS ?= $(CARGO_BUILD_ARGS) $(CARGO_WORKSPACE_ARGS) CARGO_WORKSPACE_ALL_TARGETS_BUILD_ARGS ?= $(CARGO_WORKSPACE_BUILD_ARGS) $(CARGO_ALL_TARGETS_ARGS) NEXTEST_STATUS_ARGS ?= --status-level slow --final-status-level slow -DEV_FEATURES ?= +DEV_FEATURES ?= web-ui-dynamic DEV_CARGO_FEATURE_ARGS := $(if $(strip $(DEV_FEATURES)),--features "$(DEV_FEATURES)",) RELEASE_FEATURES ?= RELEASE_CARGO_FEATURE_ARGS := $(if $(strip $(RELEASE_FEATURES)),--features "$(RELEASE_FEATURES)",) @@ -67,6 +67,7 @@ DEV_AUTH_MAX_REQUESTS ?= 10000 DEV_AUTH_WINDOW_SECONDS ?= 1 DEV_CORS_ORIGINS := ["http://localhost:3000","http://127.0.0.1:3000","http://localhost:5173","http://127.0.0.1:5173","http://localhost:8080","http://127.0.0.1:8080"] DEV_FILE_STORAGE_BACKENDS := {"database":{"type":"database"}} +DEV_WEB_UI_DIR ?= $(CURDIR)/synctv-web-ui/dist COMPOSE_DEV := $(COMPOSE) -p $(DEV_PROJECT) -f $(DEV_COMPOSE_FILE) COMPOSE_DEV_PROFILES := COMPOSE_PROFILES=media,storage,auth $(COMPOSE_DEV) @@ -84,6 +85,7 @@ export SYNCTV_LIVESTREAM_LOGGING_LEVEL="$${SYNCTV_LIVESTREAM_LOGGING_LEVEL:-debu export SYNCTV_WEBRTC_LOGGING_LEVEL="$${SYNCTV_WEBRTC_LOGGING_LEVEL:-debug}"; \ export SYNCTV_SERVER_HOST=0.0.0.0; \ export SYNCTV_SERVER_PORT=8080; \ +export SYNCTV_SERVER_WEB_UI_DIRECTORY="$(DEV_WEB_UI_DIR)"; \ export SYNCTV_SERVER_CORS_ALLOWED_ORIGINS='$(DEV_CORS_ORIGINS)'; \ export SYNCTV_JWT_SECRET="$(DEV_JWT_SECRET)"; \ export SYNCTV_CLUSTER_SECRET="$(DEV_CLUSTER_SECRET)"; \ @@ -300,6 +302,7 @@ dev-shell: dev-up ## Open a shell with SyncTV development environment variables. SYNCTV_DATA_DIR="$(DEV_DATA_DIR)" \ SYNCTV_DATABASE_URL="$(DEV_DATABASE_URL)" \ SYNCTV_REDIS_URL="$(DEV_REDIS_URL)" \ + SYNCTV_SERVER_WEB_UI_DIRECTORY="$(DEV_WEB_UI_DIR)" \ SYNCTV_JWT_SECRET="$(DEV_JWT_SECRET)" \ SYNCTV_CLUSTER_SECRET="$(DEV_CLUSTER_SECRET)" \ SYNCTV_SECURITY_CREDENTIAL_ENCRYPTION_KEY="$(DEV_CREDENTIAL_KEY)" \ diff --git a/docs/src/content/docs/configuration/server-and-runtime.mdx b/docs/src/content/docs/configuration/server-and-runtime.mdx index c9ad32ba..a89af2e1 100644 --- a/docs/src/content/docs/configuration/server-and-runtime.mdx +++ b/docs/src/content/docs/configuration/server-and-runtime.mdx @@ -47,6 +47,18 @@ TCP 监听端口可设为 `0`。SyncTV 在启动初始化阶段直接建立监 开启公开 gRPC reflection。默认关闭;本地开发和受控内网调试可以显式开启,公网生产环境应保持关闭。 +### `server.web_ui_directory` + +默认值:未设置。 + +设置后,启用了 `web-ui-dynamic` 或 `web-ui` feature 的服务会在每次请求时从该目录读取前端文件。替换目录中的构建产物后立即生效,无需重新构建或重启后端。动态文件使用 `Cache-Control: no-store`,缺少 `index.html` 时返回 `503`。相对路径基于服务进程的工作目录解析。 + +```bash +SYNCTV_SERVER_WEB_UI_DIRECTORY=/path/to/dist +``` + +动态目录优先于二进制中的内嵌资源。目录只应包含可信的前端构建产物。 + ### `server.grpc_max_message_size_bytes` 默认值:`16777216`,即 16 MiB。 diff --git a/docs/src/content/docs/en/configuration/server-and-runtime.mdx b/docs/src/content/docs/en/configuration/server-and-runtime.mdx index 31e5ef46..7e313d5b 100644 --- a/docs/src/content/docs/en/configuration/server-and-runtime.mdx +++ b/docs/src/content/docs/en/configuration/server-and-runtime.mdx @@ -49,6 +49,18 @@ Default: `false`. Enables public gRPC reflection. It is disabled by default; enable it explicitly for local development or controlled internal debugging, and keep it disabled on public production endpoints. +### `server.web_ui_directory` + +Default: unset. + +When set on a server built with the `web-ui-dynamic` or `web-ui` feature, SyncTV reads frontend files from this directory on every request. Replacing the distribution takes effect without rebuilding or restarting the backend. Dynamic files use `Cache-Control: no-store`, and a missing `index.html` returns `503`. Relative paths resolve from the server process working directory. + +```bash +SYNCTV_SERVER_WEB_UI_DIRECTORY=/path/to/dist +``` + +The dynamic directory takes precedence over assets embedded in the binary. Only place trusted frontend build output in this directory. + ### `server.grpc_max_message_size_bytes` Default: `16777216`, which is 16 MiB. diff --git a/docs/src/content/docs/en/reference/environment-variables.mdx b/docs/src/content/docs/en/reference/environment-variables.mdx index 1bbfee55..91116a7c 100644 --- a/docs/src/content/docs/en/reference/environment-variables.mdx +++ b/docs/src/content/docs/en/reference/environment-variables.mdx @@ -12,6 +12,7 @@ Most variables map directly from the configuration path: | Configuration field | Environment variable | | --- | --- | | `server.host` | `SYNCTV_SERVER_HOST` | +| `server.web_ui_directory` | `SYNCTV_SERVER_WEB_UI_DIRECTORY` | | `database.url` | `SYNCTV_DATABASE_URL` | | `security.opaque_server_setup_secret` | `SYNCTV_SECURITY_OPAQUE_SERVER_SETUP_SECRET` | | `proxy_slice_cache.enabled` | `SYNCTV_PROXY_SLICE_CACHE_ENABLED` | @@ -334,6 +335,7 @@ SYNCTV_FILE_STORAGE_BACKENDS='{"s3_public":{"type":"s3","endpoint":"https://s3.e | `SYNCTV_SERVER_PORT` | `server.port` | | `SYNCTV_SERVER_SHUTDOWN_DRAIN_TIMEOUT_SECONDS` | `server.shutdown_drain_timeout_seconds` | | `SYNCTV_SERVER_TRUSTED_PROXIES` | `server.trusted_proxies` | +| `SYNCTV_SERVER_WEB_UI_DIRECTORY` | `server.web_ui_directory` | | `SYNCTV_TIME_TIMEZONE` | `time.timezone` | | `SYNCTV_TIME_CLOCK_SYNC_ENABLED` | `time.clock_sync.enabled` | | `SYNCTV_TIME_CLOCK_SYNC_PROVIDER_TYPE` | `time.clock_sync.provider.type` | diff --git a/docs/src/content/docs/reference/environment-variables.mdx b/docs/src/content/docs/reference/environment-variables.mdx index a62c6899..f2ca69c6 100644 --- a/docs/src/content/docs/reference/environment-variables.mdx +++ b/docs/src/content/docs/reference/environment-variables.mdx @@ -11,6 +11,7 @@ description: 常用 SYNCTV_ 环境变量速查。 | `SYNCTV_DATA_DIR` | `data_dir` | | `SYNCTV_SERVER_HOST` | `server.host` | | `SYNCTV_SERVER_PORT` | `server.port` | +| `SYNCTV_SERVER_WEB_UI_DIRECTORY` | `server.web_ui_directory` | | `SYNCTV_HEALTH_ENABLED` | `health.enabled` | | `SYNCTV_HEALTH_HOST` | `health.host` | | `SYNCTV_HEALTH_PORT` | `health.port` | diff --git a/synctv-api-common/src/server_settings.rs b/synctv-api-common/src/server_settings.rs index 757d1770..741bb256 100644 --- a/synctv-api-common/src/server_settings.rs +++ b/synctv-api-common/src/server_settings.rs @@ -25,6 +25,7 @@ impl Default for AccessLogSettings { pub struct ApiServerSettings { pub bind_address: String, pub project_url: String, + pub web_ui_directory: Option, pub apple_app_ids: Vec, pub android_apps: Vec, pub trusted_proxies: Vec, @@ -39,6 +40,7 @@ impl Default for ApiServerSettings { Self { bind_address: "0.0.0.0:8080".to_string(), project_url: DEFAULT_PROJECT_URL.to_string(), + web_ui_directory: None, apple_app_ids: Vec::new(), android_apps: Vec::new(), trusted_proxies: Vec::new(), diff --git a/synctv-api-http/Cargo.toml b/synctv-api-http/Cargo.toml index ee5f8db0..bc845f24 100644 --- a/synctv-api-http/Cargo.toml +++ b/synctv-api-http/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [features] default = ["tls-aws-lc", "tls-webpki-roots"] web-ui = ["dep:synctv-web-ui", "synctv-web-ui/embed"] +web-ui-dynamic = ["dep:synctv-web-ui"] openapi = [ "synctv-api-common/openapi", "synctv-proto/openapi", @@ -181,6 +182,7 @@ rayon.workspace = true workspace = true [dev-dependencies] +tempfile.workspace = true synctv-api-common = { workspace = true, default-features = false, features = ["test-support"] } wiremock.workspace = true serde_urlencoded.workspace = true diff --git a/synctv-api-http/src/http/mod.rs b/synctv-api-http/src/http/mod.rs index 41ad7f15..68338a2e 100644 --- a/synctv-api-http/src/http/mod.rs +++ b/synctv-api-http/src/http/mod.rs @@ -25,7 +25,7 @@ pub(crate) mod room_extra; pub(crate) mod ticket; pub(crate) mod user; pub(crate) mod validation; -#[cfg(feature = "web-ui")] +#[cfg(any(feature = "web-ui", feature = "web-ui-dynamic"))] pub(crate) mod web_ui; pub(crate) mod webrtc; pub(crate) mod websocket; @@ -39,7 +39,7 @@ use axum::{ routing::{get, post}, Router, }; -#[cfg(not(feature = "web-ui"))] +#[cfg(not(any(feature = "web-ui", feature = "web-ui-dynamic")))] use axum::{extract::State, response::Redirect}; use futures::StreamExt; use std::sync::{Arc, LazyLock}; @@ -895,13 +895,13 @@ fn register_websocket_routes() -> Router { fn register_all_routes() -> Router { let mut router = Router::new(); - #[cfg(feature = "web-ui")] + #[cfg(any(feature = "web-ui", feature = "web-ui-dynamic"))] { router = router .route("/", get(web_ui::index)) .route("/{*webUiPath}", get(web_ui::fallback)); } - #[cfg(not(feature = "web-ui"))] + #[cfg(not(any(feature = "web-ui", feature = "web-ui-dynamic")))] { router = router.route("/", get(redirect_to_project)); } @@ -1554,7 +1554,7 @@ fn register_all_routes() -> Router { router } -#[cfg(not(feature = "web-ui"))] +#[cfg(not(any(feature = "web-ui", feature = "web-ui-dynamic")))] async fn redirect_to_project(State(state): State) -> Redirect { Redirect::temporary(&state.runtime_settings.server.project_url) } diff --git a/synctv-api-http/src/http/tests.rs b/synctv-api-http/src/http/tests.rs index 6e23103d..91f92652 100644 --- a/synctv-api-http/src/http/tests.rs +++ b/synctv-api-http/src/http/tests.rs @@ -22,7 +22,7 @@ use tower::ServiceExt; type TestResult = anyhow::Result; -#[cfg(feature = "web-ui")] +#[cfg(any(feature = "web-ui", feature = "web-ui-dynamic"))] #[test] fn web_ui_routes_can_merge_with_a_grpc_style_fallback() { let grpc_router = Router::::new().fallback(StatusCode::NOT_FOUND); @@ -1702,7 +1702,7 @@ async fn test_playback_patch_route_is_reachable_via_project_router() -> TestResu #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] -#[cfg(not(feature = "web-ui"))] +#[cfg(not(any(feature = "web-ui", feature = "web-ui-dynamic")))] async fn test_api_root_redirects_to_configured_project_url() -> TestResult { let mut state = test_app_state(); Arc::make_mut(&mut Arc::make_mut(&mut state.router_options).runtime_settings) diff --git a/synctv-api-http/src/http/web_ui.rs b/synctv-api-http/src/http/web_ui.rs index df49c0d0..c6f12337 100644 --- a/synctv-api-http/src/http/web_ui.rs +++ b/synctv-api-http/src/http/web_ui.rs @@ -1,7 +1,12 @@ +use std::io::ErrorKind; +use std::path::{Component, Path, PathBuf}; + use axum::body::Body; +use axum::extract::State; use axum::http::{header, HeaderMap, HeaderValue, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; +use synctv_api_common::AppState; use synctv_web_ui::{Asset, ASSETS, WEB_UI_AVAILABLE}; const PROVIDER_VERIFICATION_PAGE: &str = "provider_verification.html"; @@ -16,14 +21,22 @@ const PROVIDER_VERIFICATION_CSP: &str = "default-src 'none'; \ base-uri 'none'; \ form-action 'none'"; -pub async fn index(headers: HeaderMap) -> Response { - serve_path("index.html", true, &headers) +pub async fn index(State(state): State, headers: HeaderMap) -> Response { + serve_path(web_ui_directory(&state), "index.html", true, &headers).await +} + +pub async fn fallback(State(state): State, uri: Uri, headers: HeaderMap) -> Response { + fallback_response(web_ui_directory(&state), &uri, &headers).await +} + +fn web_ui_directory(state: &AppState) -> Option<&Path> { + state.runtime_settings.server.web_ui_directory.as_deref() } -pub async fn fallback(uri: Uri, headers: HeaderMap) -> Response { +async fn fallback_response(directory: Option<&Path>, uri: &Uri, headers: &HeaderMap) -> Response { let path = uri.path().trim_start_matches('/'); if path.is_empty() { - return serve_path("index.html", true, &headers); + return serve_path(directory, "index.html", true, headers).await; } if path.starts_with("api/") || path == "api" @@ -34,11 +47,13 @@ pub async fn fallback(uri: Uri, headers: HeaderMap) -> Response { { return not_found(); } - if path.contains("..") || path.contains('\\') { + if !safe_asset_path(path) { return not_found(); } - if let Some(response) = find_asset(path).map(|asset| asset_response(asset, false, &headers)) { - return response; + match find_response(directory, path, false, headers).await { + Ok(Some(response)) => return response, + Ok(None) => {} + Err(()) => return unavailable(), } let accepts_html = headers @@ -52,23 +67,44 @@ pub async fn fallback(uri: Uri, headers: HeaderMap) -> Response { }) }); if !path.contains('.') && (accepts_html || headers.get(header::ACCEPT).is_none()) { - return serve_path("index.html", true, &headers); + return serve_path(directory, "index.html", true, headers).await; } not_found() } -fn serve_path(path: &str, html_navigation: bool, headers: &HeaderMap) -> Response { +async fn serve_path( + directory: Option<&Path>, + path: &str, + html_navigation: bool, + headers: &HeaderMap, +) -> Response { + match find_response(directory, path, html_navigation, headers).await { + Ok(Some(response)) => response, + Ok(None) if directory.is_some() && path == "index.html" => unavailable(), + Ok(None) => not_found(), + Err(()) => unavailable(), + } +} + +async fn find_response( + directory: Option<&Path>, + path: &str, + html_navigation: bool, + headers: &HeaderMap, +) -> Result, ()> { + if let Some(directory) = directory { + return disk_asset_response(directory, path, headers) + .await + .map(Some) + .or_else(|error| match error { + DiskAssetError::Missing => Ok(None), + DiskAssetError::Unavailable => Err(()), + }); + } if !WEB_UI_AVAILABLE { - return ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - "The embedded SyncTV Web client is not available in this build.", - ) - .into_response(); + return Err(()); } - find_asset(path).map_or_else(not_found, |asset| { - asset_response(asset, html_navigation, headers) - }) + Ok(find_asset(path).map(|asset| asset_response(asset, html_navigation, headers))) } fn find_asset(path: &str) -> Option<&'static Asset> { @@ -118,6 +154,93 @@ fn asset_response(asset: &'static Asset, html_navigation: bool, headers: &Header response } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DiskAssetError { + Missing, + Unavailable, +} + +async fn disk_asset_response( + directory: &Path, + route: &str, + headers: &HeaderMap, +) -> Result { + let path = resolve_disk_asset(directory, route).await?; + if accepted_encoding_qualities(headers).identity == 0 { + return Ok(StatusCode::NOT_ACCEPTABLE.into_response()); + } + let bytes = tokio::fs::read(&path) + .await + .map_err(|_| DiskAssetError::Unavailable)?; + let mut response = Response::new(Body::from(bytes)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(route)), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + apply_asset_security_headers(route, response.headers_mut()); + Ok(response) +} + +async fn resolve_disk_asset(directory: &Path, route: &str) -> Result { + if !safe_asset_path(route) { + return Err(DiskAssetError::Missing); + } + let root = tokio::fs::canonicalize(directory) + .await + .map_err(|_| DiskAssetError::Unavailable)?; + let candidate = tokio::fs::canonicalize(root.join(route)) + .await + .map_err(|error| match error.kind() { + ErrorKind::NotFound => DiskAssetError::Missing, + _ => DiskAssetError::Unavailable, + })?; + if !candidate.starts_with(&root) { + return Err(DiskAssetError::Missing); + } + let metadata = tokio::fs::metadata(&candidate) + .await + .map_err(|_| DiskAssetError::Unavailable)?; + if !metadata.is_file() { + return Err(DiskAssetError::Missing); + } + Ok(candidate) +} + +fn safe_asset_path(path: &str) -> bool { + !path.is_empty() + && !path.contains('\\') + && Path::new(path) + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +fn content_type(path: &str) -> &'static str { + match Path::new(path) + .extension() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "html" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "json" | "map" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "woff" => "font/woff", + "woff2" => "font/woff2", + _ => "application/octet-stream", + } +} + fn apply_asset_security_headers(path: &str, headers: &mut HeaderMap) { if path != PROVIDER_VERIFICATION_PAGE { return; @@ -350,9 +473,19 @@ fn not_found() -> Response { StatusCode::NOT_FOUND.into_response() } +fn unavailable() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + "The SyncTV Web client is not available from the configured source.", + ) + .into_response() +} + #[cfg(test)] mod tests { use super::*; + use http_body_util::BodyExt; const ETAG: &str = "\"0123456789abcdef-42\""; @@ -405,12 +538,17 @@ mod tests { #[tokio::test] async fn root_serves_spa_entrypoint_without_redirect() { - assert!( - WEB_UI_AVAILABLE, - "the web-ui feature must embed a SPA entrypoint" - ); - - let response = index(HeaderMap::new()).await; + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + std::fs::write(directory.path().join("index.html"), "dynamic index") + .expect("write SPA entrypoint"); + + let response = serve_path( + Some(directory.path()), + "index.html", + true, + &HeaderMap::new(), + ) + .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -419,29 +557,127 @@ mod tests { ); assert_eq!( response.headers().get(header::CACHE_CONTROL), - Some(&HeaderValue::from_static("no-cache")) + Some(&HeaderValue::from_static("no-store")) ); assert!(response.headers().get(header::LOCATION).is_none()); } #[tokio::test] async fn oauth_callback_uses_the_spa_entrypoint() { - let response = fallback( - "/oauth2/callback" - .parse::() - .expect("valid callback URI"), - HeaderMap::new(), - ) - .await; + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + std::fs::write(directory.path().join("index.html"), "dynamic index") + .expect("write SPA entrypoint"); + let uri = "/oauth2/callback" + .parse::() + .expect("valid callback URI"); + let response = fallback_response(Some(directory.path()), &uri, &HeaderMap::new()).await; - if !WEB_UI_AVAILABLE { - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - return; - } assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers().get(header::CACHE_CONTROL), - Some(&HeaderValue::from_static("no-cache")) + Some(&HeaderValue::from_static("no-store")) + ); + } + + #[tokio::test] + async fn disk_assets_are_reloaded_after_replacement() { + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + let asset = directory.path().join("main.dart.js"); + std::fs::write(&asset, "first build").expect("write first build"); + + let first = serve_path( + Some(directory.path()), + "main.dart.js", + false, + &HeaderMap::new(), + ) + .await; + let first = first + .into_body() + .collect() + .await + .expect("read first response") + .to_bytes(); + std::fs::write(&asset, "second build").expect("write second build"); + let second = serve_path( + Some(directory.path()), + "main.dart.js", + false, + &HeaderMap::new(), + ) + .await; + let second = second + .into_body() + .collect() + .await + .expect("read second response") + .to_bytes(); + + assert_eq!(first.as_ref(), b"first build"); + assert_eq!(second.as_ref(), b"second build"); + } + + #[tokio::test] + async fn disk_assets_honor_identity_encoding_exclusion() { + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + std::fs::write(directory.path().join("main.dart.js"), "dynamic build") + .expect("write dynamic build"); + let mut headers = HeaderMap::new(); + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("br;q=0, gzip;q=0, identity;q=0"), + ); + + let response = serve_path(Some(directory.path()), "main.dart.js", false, &headers).await; + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + } + + #[tokio::test] + async fn unavailable_or_incomplete_disk_source_does_not_use_embedded_assets() { + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + let missing = directory.path().join("missing"); + + let missing_directory = + serve_path(Some(&missing), "index.html", true, &HeaderMap::new()).await; + let missing_entrypoint = serve_path( + Some(directory.path()), + "index.html", + true, + &HeaderMap::new(), + ) + .await; + + assert_eq!(missing_directory.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(missing_entrypoint.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn disk_source_rejects_path_traversal() { + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + + assert_eq!( + resolve_disk_asset(directory.path(), "../secret").await, + Err(DiskAssetError::Missing) + ); + assert_eq!( + resolve_disk_asset(directory.path(), "nested\\secret").await, + Err(DiskAssetError::Missing) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn disk_source_rejects_symlinks_outside_the_root() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("temporary Web UI directory"); + let outside = tempfile::NamedTempFile::new().expect("outside file"); + symlink(outside.path(), directory.path().join("escaped.js")).expect("create symlink"); + + assert_eq!( + resolve_disk_asset(directory.path(), "escaped.js").await, + Err(DiskAssetError::Missing) ); } @@ -553,11 +789,13 @@ mod tests { .iter() .find(|asset| asset.brotli.is_some() && asset.gzip.is_some()) .expect("the Web UI build should contain a compressible asset"); - let uri = format!("/{}", asset.path).parse::().unwrap(); + let uri = format!("/{}", asset.path) + .parse::() + .expect("embedded asset path should form a valid URI"); let mut brotli_headers = HeaderMap::new(); brotli_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); - let brotli_response = fallback(uri.clone(), brotli_headers).await; + let brotli_response = fallback_response(None, &uri, &brotli_headers).await; assert_eq!(brotli_response.status(), StatusCode::OK); assert_eq!( brotli_response.headers().get(header::CONTENT_ENCODING), @@ -575,7 +813,7 @@ mod tests { let mut gzip_headers = HeaderMap::new(); gzip_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")); - let gzip_response = fallback(uri.clone(), gzip_headers).await; + let gzip_response = fallback_response(None, &uri, &gzip_headers).await; assert_eq!(gzip_response.status(), StatusCode::OK); assert_eq!( gzip_response.headers().get(header::CONTENT_ENCODING), @@ -589,7 +827,7 @@ mod tests { let mut revalidation_headers = HeaderMap::new(); revalidation_headers.insert(header::ACCEPT_ENCODING, HeaderValue::from_static("br")); revalidation_headers.insert(header::IF_NONE_MATCH, brotli_etag.clone()); - let revalidation_response = fallback(uri.clone(), revalidation_headers).await; + let revalidation_response = fallback_response(None, &uri, &revalidation_headers).await; assert_eq!(revalidation_response.status(), StatusCode::NOT_MODIFIED); assert_eq!( revalidation_response @@ -607,7 +845,7 @@ mod tests { header::ACCEPT_ENCODING, HeaderValue::from_static("br;q=0, gzip;q=0, identity;q=0"), ); - let rejected_response = fallback(uri, rejected_headers).await; + let rejected_response = fallback_response(None, &uri, &rejected_headers).await; assert_eq!(rejected_response.status(), StatusCode::NOT_ACCEPTABLE); } } diff --git a/synctv-api/Cargo.toml b/synctv-api/Cargo.toml index af3b5ada..4d297bac 100644 --- a/synctv-api/Cargo.toml +++ b/synctv-api/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [features] default = ["tls-aws-lc", "tls-webpki-roots"] web-ui = ["synctv-api-http/web-ui"] +web-ui-dynamic = ["synctv-api-http/web-ui-dynamic"] openapi = ["synctv-api-common/openapi", "synctv-api-http/openapi"] k8s = [ "synctv-api-common/k8s", diff --git a/synctv-web-ui/README.md b/synctv-web-ui/README.md index 19dadbe9..1fb5bcbb 100644 --- a/synctv-web-ui/README.md +++ b/synctv-web-ui/README.md @@ -1,14 +1,13 @@ # SyncTV Web UI assets -This crate owns the acquisition, optional Flutter build, compression, manifest, -and compile-time embedding of the SyncTV browser client. `synctv-api-http` -only serves the generated asset table. +This crate owns acquisition, optional Flutter builds, compression, manifests, +and compile-time embedding of the SyncTV browser client. `synctv-api-http` can +also serve a distribution directly from disk during development. ## Sources `web-ui.toml` reads prebuilt files from `dist/`. The directory is empty in Git -apart from `.gitkeep`; place a Web distribution there before enabling the -server's `web-ui` feature. +apart from `.gitkeep`. `web-ui.production.toml` is the versioned production source used by CI. Its Git source pins both the requested revision and its expected full lowercase commit @@ -57,6 +56,19 @@ fingerprint. ## Commands +Run the server against a mutable local distribution: + +```bash +make dev-serve +``` + +`dev-serve` enables the `web-ui-dynamic` feature and sets +`SYNCTV_SERVER_WEB_UI_DIRECTORY` to `synctv-web-ui/dist`. Override the directory +with `DEV_WEB_UI_DIR=/path/to/dist`. Files are read for every request, use +`Cache-Control: no-store`, and can be replaced without rebuilding or restarting +the Rust server. A relative runtime directory is resolved from the server's +working directory. + Build and export the Web distribution: ```bash @@ -70,6 +82,11 @@ Build the release server with the assets embedded: make web-release-build ``` +The existing `web-ui` feature embeds the distribution for release deployment. +Both features expose the same routes. When `server.web_ui_directory` is +configured, its disk contents are authoritative and take precedence over +embedded assets. + The Web-only command exports to `target/web-ui-dist` by default. CI uploads the exported distribution once, then passes its authenticated artifact URL and SHA-256 digest to the existing multi-platform Docker build. Docker verifies and diff --git a/synctv/Cargo.toml b/synctv/Cargo.toml index 3c444f82..cfb542fd 100644 --- a/synctv/Cargo.toml +++ b/synctv/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" # OpenAPI is opt-in through the `openapi` feature below. default = ["tls-aws-lc", "tls-webpki-roots"] web-ui = ["synctv-api/web-ui"] +web-ui-dynamic = ["synctv-api/web-ui-dynamic"] # Enable Kubernetes support (K8s DNS discovery, K8s Lease leader election). # Disable to reduce binary size for non-K8s deployments: # cargo build --no-default-features diff --git a/synctv/src/app.rs b/synctv/src/app.rs index c5889638..c3324a73 100644 --- a/synctv/src/app.rs +++ b/synctv/src/app.rs @@ -2059,6 +2059,7 @@ mod tests { host: "127.0.0.1".to_string(), port: 8080, project_url: synctv_api::DEFAULT_PROJECT_URL.to_string(), + web_ui_directory: None, enable_reflection: false, grpc_max_message_size_bytes: 16 * 1024 * 1024, grpc_compression_enabled: true, diff --git a/synctv/src/app_config/mod.rs b/synctv/src/app_config/mod.rs index eb8b5a51..4ef3feed 100644 --- a/synctv/src/app_config/mod.rs +++ b/synctv/src/app_config/mod.rs @@ -92,6 +92,7 @@ pub struct ServerConfig { pub host: String, pub port: u16, pub project_url: String, + pub web_ui_directory: Option, pub enable_reflection: bool, pub trusted_proxies: Vec, pub cors_allowed_origins: Vec, @@ -109,6 +110,7 @@ impl Default for ServerConfig { host: "0.0.0.0".to_string(), port: 8080, project_url: synctv_api::DEFAULT_PROJECT_URL.to_string(), + web_ui_directory: None, enable_reflection: false, trusted_proxies: Vec::new(), cors_allowed_origins: Vec::new(), diff --git a/synctv/src/app_config/validation.rs b/synctv/src/app_config/validation.rs index 5fc3b571..49bb1181 100644 --- a/synctv/src/app_config/validation.rs +++ b/synctv/src/app_config/validation.rs @@ -510,6 +510,14 @@ impl AppConfig { if let Err(error) = validate_project_url(&self.server.project_url) { errors.push(error); } + if self + .server + .web_ui_directory + .as_ref() + .is_some_and(|path| path.as_os_str().is_empty()) + { + errors.push("server.web_ui_directory must not be empty when configured".to_string()); + } if self.metrics.enabled { match self.metrics.auth.mode { @@ -1419,6 +1427,18 @@ mod tests { } } + #[test] + fn web_ui_directory_rejects_an_empty_path() { + let mut config = AppConfig::default(); + config.server.web_ui_directory = Some(std::path::PathBuf::new()); + + let errors = config.validate().expect_err("empty Web UI path must fail"); + + assert!(errors + .iter() + .any(|error| error.contains("server.web_ui_directory must not be empty"))); + } + #[test] fn logging_validation_covers_global_and_network_components() { let mut config = AppConfig::default(); diff --git a/synctv/src/config_env.rs b/synctv/src/config_env.rs index cc0e64ed..c161412c 100644 --- a/synctv/src/config_env.rs +++ b/synctv/src/config_env.rs @@ -413,6 +413,9 @@ pub(crate) fn apply_env_overrides_with( env_override_str("SYNCTV_SERVER_HOST", &mut config.server.host); env_override_parse("SYNCTV_SERVER_PORT", &mut config.server.port)?; + if let Some(path) = get_env("SYNCTV_SERVER_WEB_UI_DIRECTORY") { + config.server.web_ui_directory = Some(PathBuf::from(path)); + } env_override_bool( "SYNCTV_SERVER_ENABLE_REFLECTION", &mut config.server.enable_reflection, @@ -1366,6 +1369,20 @@ mod tests { const EMAIL_OUTBOX_KEY: &str = "5757575757575757575757575757575757575757575757575757575757575757"; + #[test] + fn web_ui_directory_accepts_environment_override() { + let mut config = Config::default(); + let env = HashMap::from([("SYNCTV_SERVER_WEB_UI_DIRECTORY", "web-ui/dist".to_string())]); + + apply_env_overrides_with(&mut config, &|name| env.get(name).cloned()) + .expect("environment override should apply"); + + assert_eq!( + config.server.web_ui_directory, + Some(PathBuf::from("web-ui/dist")) + ); + } + #[test] fn email_outbox_key_accepts_direct_environment_override() { let mut config = Config::default(); diff --git a/synctv/src/resource_options.rs b/synctv/src/resource_options.rs index a8f0bea7..a0c4fac2 100644 --- a/synctv/src/resource_options.rs +++ b/synctv/src/resource_options.rs @@ -625,6 +625,7 @@ pub fn api_runtime_settings(config: &AppConfig) -> ApiRuntimeSettings { server: ApiServerSettings { bind_address: config.api_address(), project_url: config.server.project_url.clone(), + web_ui_directory: config.server.web_ui_directory.clone(), apple_app_ids: config.webauthn.apple_app_ids.clone(), android_apps: config .webauthn diff --git a/synctv/tests/cluster_startup_failure_tests.rs b/synctv/tests/cluster_startup_failure_tests.rs index 802f829d..561cf504 100644 --- a/synctv/tests/cluster_startup_failure_tests.rs +++ b/synctv/tests/cluster_startup_failure_tests.rs @@ -64,6 +64,7 @@ fn standalone_test_config() -> Config { host: "127.0.0.1".to_string(), port: 8080, project_url: synctv_api::DEFAULT_PROJECT_URL.to_string(), + web_ui_directory: None, enable_reflection: false, grpc_max_message_size_bytes: 16 * 1024 * 1024, grpc_compression_enabled: true, @@ -120,6 +121,7 @@ fn cluster_test_config() -> Config { host: "127.0.0.1".to_string(), port: 8080, project_url: synctv_api::DEFAULT_PROJECT_URL.to_string(), + web_ui_directory: None, enable_reflection: false, grpc_max_message_size_bytes: 16 * 1024 * 1024, grpc_compression_enabled: true,