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`
pull/441/head
zijiren 1 month ago committed by GitHub
parent 0778c2b23e
commit 9ebfef16e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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.

@ -7859,6 +7859,7 @@ dependencies = [
"synctv-proxy",
"synctv-realtime",
"synctv-web-ui",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-stream",

@ -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)" \

@ -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。

@ -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.

@ -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` |

@ -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` |

@ -25,6 +25,7 @@ impl Default for AccessLogSettings {
pub struct ApiServerSettings {
pub bind_address: String,
pub project_url: String,
pub web_ui_directory: Option<std::path::PathBuf>,
pub apple_app_ids: Vec<String>,
pub android_apps: Vec<AndroidAppAssociationSettings>,
pub trusted_proxies: Vec<String>,
@ -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(),

@ -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

@ -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<AppState> {
fn register_all_routes() -> Router<AppState> {
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<AppState> {
router
}
#[cfg(not(feature = "web-ui"))]
#[cfg(not(any(feature = "web-ui", feature = "web-ui-dynamic")))]
async fn redirect_to_project(State(state): State<AppState>) -> Redirect {
Redirect::temporary(&state.runtime_settings.server.project_url)
}

@ -22,7 +22,7 @@ use tower::ServiceExt;
type TestResult<T = ()> = anyhow::Result<T>;
#[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::<super::AppState>::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)

@ -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<AppState>, headers: HeaderMap) -> Response {
serve_path(web_ui_directory(&state), "index.html", true, &headers).await
}
pub async fn fallback(State(state): State<AppState>, 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<Option<Response>, ()> {
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<Response, DiskAssetError> {
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<PathBuf, DiskAssetError> {
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::<Uri>()
.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::<Uri>()
.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::<Uri>().unwrap();
let uri = format!("/{}", asset.path)
.parse::<Uri>()
.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);
}
}

@ -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",

@ -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

@ -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

@ -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,

@ -92,6 +92,7 @@ pub struct ServerConfig {
pub host: String,
pub port: u16,
pub project_url: String,
pub web_ui_directory: Option<PathBuf>,
pub enable_reflection: bool,
pub trusted_proxies: Vec<String>,
pub cors_allowed_origins: Vec<String>,
@ -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(),

@ -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();

@ -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();

@ -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

@ -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,

Loading…
Cancel
Save