diff --git a/.github/workflows/build-distributions.yml b/.github/workflows/build-distributions.yml index e2c5b8e127..547a47cf47 100644 --- a/.github/workflows/build-distributions.yml +++ b/.github/workflows/build-distributions.yml @@ -123,6 +123,18 @@ jobs: if: inputs.prebuilt-run-id == '' with: toolchain: stable + - uses: actions/setup-node@v4 + if: inputs.prebuilt-run-id == '' + with: + node-version: "22" + cache: npm + cache-dependency-path: ui/package-lock.json + - name: Build the web UI that sqb server embeds + if: inputs.prebuilt-run-id == '' + working-directory: ui + run: | + npm ci + npm run build - uses: PyO3/maturin-action@v1 if: inputs.prebuilt-run-id == '' with: @@ -142,10 +154,12 @@ jobs: env: RUSTFLAGS: >- ${{ runner.os == 'macOS' && '-C link-arg=-undefined -C link-arg=dynamic_lookup' || '' }} + # A wheel must never ship a sqb server without its UI. + SQLBUILD_UI_REQUIRED: "1" with: command: build args: >- - --release --out dist --manifest-path crates/sqlbuild-cli/Cargo.toml + --release --out dist --manifest-path crates/sqlbuild-cli/Cargo.toml --features ui ${{ matrix.platform.name == 'linux-x86_64' && '--timings' || '' }} target: ${{ matrix.platform.target }} manylinux: auto @@ -213,6 +227,19 @@ jobs: printf 'MODEL();\nSELECT 1 AS id\n' > "${project}/models/orders.sql" "${smoke}/sqb" --project-dir "${project}" scope model:orders --json "${smoke}/sqlbuild" --project-dir "${project}" scope model:orders + # The packaged sqb server must serve the embedded UI, not only the API. + server_dir="${RUNNER_TEMP}/ui-server-smoke" + mkdir -p "${server_dir}" + (cd "${server_dir}" && env -u SQLBUILD_CONTROL_DATABASE_URL \ + "${smoke}/sqb" server --host 127.0.0.1 --port 8765 --no-scheduler) & + server_pid=$! + for _ in $(seq 1 60); do + curl -sf -o /dev/null http://127.0.0.1:8765/api/openapi.json && break + sleep 1 + done + curl -sf http://127.0.0.1:8765/runs | grep -q '/_app/immutable/' + curl -sfI http://127.0.0.1:8765/theme.js | grep -qi '^content-type: text/javascript' + kill "${server_pid}" - uses: actions/upload-artifact@v4 if: inputs.upload with: diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 9fd5d4e42c..8d84971ec8 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -148,13 +148,13 @@ jobs: *) docs_only=false ;; esac case "${path}" in - crates/*|Cargo.toml|Cargo.lock|pyproject.toml|.github/workflows/verify.yml|.github/workflows/build-distributions.yml|deploy/Dockerfile|deploy/project/*|deploy/compose*|compose.yml|.dockerignore|Makefile|tests/integration/src/sqlbuild/compiler/compiled_project/publication/test_compose.py|tests/integration/src/sqlbuild/compiler/compiled_project/publication/docker/compose/*|benchmarks/duckshop/edge_cases/orchestration/container_deployment.json) + crates/*|ui/*|Cargo.toml|Cargo.lock|pyproject.toml|.github/workflows/verify.yml|.github/workflows/build-distributions.yml|deploy/Dockerfile|deploy/project/*|deploy/compose*|compose.yml|.dockerignore|Makefile|tests/integration/src/sqlbuild/compiler/compiled_project/publication/test_compose.py|tests/integration/src/sqlbuild/compiler/compiled_project/publication/docker/compose/*|benchmarks/duckshop/edge_cases/orchestration/container_deployment.json) wheels=true ;; esac case "${path}" in ui/*|clients/typescript/*|.github/workflows/verify.yml) - # The web UI is not packaged; its own checks and unit tests cover it. It compiles + # The web UI's own checks and unit tests; the wheels embed its build. It compiles # against the generated control client, so client changes run them too. ui=true ;; @@ -1144,6 +1144,8 @@ jobs: run: npm run check - name: Unit tests run: npm test + - name: Build the static bundle sqb server embeds + run: npm run build verify: # The required Verify of a Release Please pull request is the commit status that the trusted diff --git a/crates/sqlbuild-cli/Cargo.toml b/crates/sqlbuild-cli/Cargo.toml index cf91026b32..ff07bf8f81 100644 --- a/crates/sqlbuild-cli/Cargo.toml +++ b/crates/sqlbuild-cli/Cargo.toml @@ -15,6 +15,9 @@ path = "src/bin/sqb.rs" name = "sqlbuild" path = "src/bin/sqlbuild.rs" +[features] +ui = ["sqlbuild-orchestration/ui"] + [dependencies] blake3.workspace = true chrono.workspace = true diff --git a/crates/sqlbuild-orchestration/Cargo.toml b/crates/sqlbuild-orchestration/Cargo.toml index f680f7a1ea..bd64823fdf 100644 --- a/crates/sqlbuild-orchestration/Cargo.toml +++ b/crates/sqlbuild-orchestration/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true publish.workspace = true rust-version.workspace = true +[features] +ui = [] + [dependencies] argon2.workspace = true axum.workspace = true diff --git a/crates/sqlbuild-orchestration/build.rs b/crates/sqlbuild-orchestration/build.rs new file mode 100644 index 0000000000..f64de044da --- /dev/null +++ b/crates/sqlbuild-orchestration/build.rs @@ -0,0 +1,68 @@ +//! With the `ui` feature, embed the built web UI (`ui/build`, or `SQLBUILD_UI_DIST`) for `sqb server`. + +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo:rerun-if-env-changed=SQLBUILD_UI_DIST"); + println!("cargo:rerun-if-env-changed=SQLBUILD_UI_REQUIRED"); + let output = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")).join("ui_assets.rs"); + let mut files = Vec::new(); + if std::env::var_os("CARGO_FEATURE_UI").is_some() { + let dist = std::env::var_os("SQLBUILD_UI_DIST").map_or_else( + || { + Path::new(&std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest")) + .join("../../ui/build") + }, + PathBuf::from, + ); + println!("cargo:rerun-if-changed={}", dist.display()); + if dist.join("index.html").is_file() { + collect(&dist, &dist, &mut files); + } else { + let message = format!( + "sqb server will not serve the UI: {} has no index.html; run `npm ci && npm run build` in ui/ or set SQLBUILD_UI_DIST", + dist.display() + ); + assert!( + std::env::var_os("SQLBUILD_UI_REQUIRED").is_none(), + "{message}" + ); + println!("cargo:warning={message}"); + } + } + files.sort(); + let entries: String = files + .iter() + .map(|(route, file)| { + format!( + " UiAsset {{ path: {route:?}, body: include_bytes!({:?}) }},\n", + file.display() + ) + }) + .collect(); + std::fs::write( + output, + format!("pub(crate) static UI_ASSETS: &[UiAsset] = &[\n{entries}];\n"), + ) + .expect("write the embedded UI table"); +} + +fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, PathBuf)>) { + for entry in std::fs::read_dir(directory).expect("read the UI build") { + let path = entry.expect("UI build entry").path(); + if path.is_dir() { + collect(root, &path, files); + } else { + let relative = path.strip_prefix(root).expect("inside the UI build"); + let route = relative + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/"); + files.push(( + format!("/{route}"), + path.canonicalize().expect("absolute UI asset path"), + )); + } + } +} diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs index 66d3fd6e36..88a30e20e2 100644 --- a/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/mod.rs @@ -16,4 +16,5 @@ pub(crate) mod run_reads; pub(crate) mod runs; pub(crate) mod snapshots; pub(crate) mod trigger_reads; +pub(crate) mod ui_assets; pub(crate) mod warehouse_metadata; diff --git a/crates/sqlbuild-orchestration/src/api/_helpers/resources/ui_assets.rs b/crates/sqlbuild-orchestration/src/api/_helpers/resources/ui_assets.rs new file mode 100644 index 0000000000..db71da46b4 --- /dev/null +++ b/crates/sqlbuild-orchestration/src/api/_helpers/resources/ui_assets.rs @@ -0,0 +1,107 @@ +//! Serve the embedded web UI for every GET outside the API, falling back to its index for client routes. + +use axum::Router; +use axum::http::{HeaderValue, Method, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; + +use crate::api::constants::{ + API_PATH, API_PREFIX, AUTH_PREFIX, ROOT_PATH, UI_DOT_SEGMENTS, UI_DOUBLE_SLASH, + UI_FRAME_POLICY, UI_IMMUTABLE_PREFIX, UI_INDEX_PATH, UI_REFUSED_ENCODINGS, +}; +use crate::api::models::UiAsset; + +#[cfg(feature = "ui")] +include!(concat!(env!("OUT_DIR"), "/ui_assets.rs")); +#[cfg(not(feature = "ui"))] +pub(crate) static UI_ASSETS: &[UiAsset] = &[]; + +pub(crate) fn serve_ui(application: Router, assets: &'static [UiAsset]) -> Router { + if assets.is_empty() { + return application; + } + application.fallback(move |method: Method, uri: Uri| async move { + respond(assets, &method, uri.path()) + }) +} + +fn respond(assets: &'static [UiAsset], method: &Method, path: &str) -> Response { + let outside_ui = path == API_PATH + || path.starts_with(API_PREFIX) + || path.starts_with(AUTH_PREFIX) + || !canonical(path); + if outside_ui || (method != Method::GET && method != Method::HEAD) { + return StatusCode::NOT_FOUND.into_response(); + } + let wanted = if path == ROOT_PATH { + UI_INDEX_PATH + } else { + path + }; + let file_like = wanted + .rsplit('/') + .next() + .is_some_and(|name| name.contains('.')); + let found = assets.iter().find(|asset| asset.path == wanted); + let asset = match found { + Some(asset) => asset, + None if file_like => return StatusCode::NOT_FOUND.into_response(), + None => match assets.iter().find(|asset| asset.path == UI_INDEX_PATH) { + Some(index) => index, + None => return StatusCode::NOT_FOUND.into_response(), + }, + }; + let caching = if asset.path.starts_with(UI_IMMUTABLE_PREFIX) { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + let mut response = asset.body.into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(content_type(asset.path)), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(caching)); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(UI_FRAME_POLICY), + ); + response +} + +/// Only plain, already-normalised paths can name an embedded file or the shell. +fn canonical(path: &str) -> bool { + let lowered = path.to_ascii_lowercase(); + !path.contains('\\') + && !path.contains(UI_DOUBLE_SLASH) + && !UI_REFUSED_ENCODINGS + .iter() + .any(|encoded| lowered.contains(encoded)) + && path + .split('/') + .all(|segment| !UI_DOT_SEGMENTS.contains(&segment)) +} + +fn content_type(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or_default() { + "html" => "text/html; charset=utf-8", + "js" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json", + "txt" => "text/plain; charset=utf-8", + "png" => "image/png", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "woff" => "font/woff", + "woff2" => "font/woff2", + _ => "application/octet-stream", + } +} diff --git a/crates/sqlbuild-orchestration/src/api/constants.rs b/crates/sqlbuild-orchestration/src/api/constants.rs index b360c21b28..9bd3b7c6f9 100644 --- a/crates/sqlbuild-orchestration/src/api/constants.rs +++ b/crates/sqlbuild-orchestration/src/api/constants.rs @@ -13,3 +13,14 @@ pub(crate) const OIDC_RESPONSE_BYTES: usize = 1024 * 1024; pub(crate) const DOCKER_IMAGE_HEX_LENGTH: usize = 64; pub(crate) const HTTPS_SCHEME: &str = "https"; pub(crate) const ROOT_PATH: &str = "/"; +pub(crate) const UI_INDEX_PATH: &str = "/index.html"; +pub(crate) const UI_IMMUTABLE_PREFIX: &str = "/_app/immutable/"; +/// The page's own meta policy carries every other directive; a meta tag cannot set frame-ancestors. +pub(crate) const UI_FRAME_POLICY: &str = "frame-ancestors 'none'"; +pub(crate) const API_PATH: &str = "/api"; +pub(crate) const API_PREFIX: &str = "/api/"; +pub(crate) const AUTH_PREFIX: &str = "/auth/"; +/// Percent-encoded dot, slash and backslash: a UI path carrying one is refused, never decoded. +pub(crate) const UI_REFUSED_ENCODINGS: [&str; 3] = ["%2e", "%2f", "%5c"]; +pub(crate) const UI_DOT_SEGMENTS: [&str; 2] = [".", ".."]; +pub(crate) const UI_DOUBLE_SLASH: &str = "//"; diff --git a/crates/sqlbuild-orchestration/src/api/main/serve_api.rs b/crates/sqlbuild-orchestration/src/api/main/serve_api.rs index 9186a7c139..310577350e 100644 --- a/crates/sqlbuild-orchestration/src/api/main/serve_api.rs +++ b/crates/sqlbuild-orchestration/src/api/main/serve_api.rs @@ -4,6 +4,7 @@ use axum::routing::get; use axum::{Extension, Json, Router, middleware}; use crate::api::_helpers::authentication::{bearer::local_request, configure::configure}; +use crate::api::_helpers::resources::ui_assets::{UI_ASSETS, serve_ui}; use crate::api::_helpers::resources::{changes, router::router}; use crate::api::main::openapi_document::openapi_document; use crate::api::models::{ @@ -77,6 +78,7 @@ fn development_application( scope: Option, address: std::net::SocketAddr, ) -> Router { + let application = serve_ui(application, UI_ASSETS); match scope { Some(scope) => application .layer(Extension(scope)) diff --git a/crates/sqlbuild-orchestration/src/api/models.rs b/crates/sqlbuild-orchestration/src/api/models.rs index 74500a964c..e0c65b37f2 100644 --- a/crates/sqlbuild-orchestration/src/api/models.rs +++ b/crates/sqlbuild-orchestration/src/api/models.rs @@ -418,3 +418,10 @@ pub(crate) struct ControlClient { pub(crate) authorization: reqwest::header::HeaderValue, pub(crate) base_url: reqwest::Url, } + +/// One file of the built web UI, embedded at compile time and served at `path`. +#[derive(Debug)] +pub(crate) struct UiAsset { + pub(crate) path: &'static str, + pub(crate) body: &'static [u8], +} diff --git a/crates/sqlbuild-orchestration/tests/api/helpers.rs b/crates/sqlbuild-orchestration/tests/api/helpers.rs index 23df808669..daa60882a2 100644 --- a/crates/sqlbuild-orchestration/tests/api/helpers.rs +++ b/crates/sqlbuild-orchestration/tests/api/helpers.rs @@ -80,3 +80,24 @@ pub(crate) async fn read_json( assert_eq!(response.headers()["cache-control"], "no-store"); response.json().await.expect("typed JSON response") } + +/// Send `GET ` byte for byte, so no client normalises dot segments, slashes or encodings. +pub(crate) fn raw_get(address: &str, path: &str) -> (u16, String) { + use std::io::{Read, Write}; + let mut stream = std::net::TcpStream::connect(address).expect("raw socket"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n" + ) + .expect("raw request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("raw response"); + let (head, body) = response.split_once("\r\n\r\n").expect("HTTP head"); + let status = head + .split(' ') + .nth(1) + .expect("status") + .parse() + .expect("numeric status"); + (status, body.to_owned()) +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs index 61356c9075..b59038211b 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_contracts.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_contracts.rs @@ -76,6 +76,8 @@ mod test_reads; mod test_statistics_recovery; #[path = "test_types.rs"] mod test_types; +#[path = "test_ui_assets.rs"] +mod test_ui_assets; #[path = "test_warehouse_metadata.rs"] mod test_warehouse_metadata; #[path = "test_worker.rs"] diff --git a/crates/sqlbuild-orchestration/tests/api/test_types.rs b/crates/sqlbuild-orchestration/tests/api/test_types.rs index 2f913bacfc..5d3d2b50f3 100644 --- a/crates/sqlbuild-orchestration/tests/api/test_types.rs +++ b/crates/sqlbuild-orchestration/tests/api/test_types.rs @@ -140,3 +140,19 @@ pub(crate) struct CustomRoleApiTestCase { pub(crate) backend: Backend, pub(crate) expected_statuses: [u16; 2], } + +pub(crate) struct UiAssetTestCase { + pub(crate) description: &'static str, + pub(crate) method: reqwest::Method, + pub(crate) path: &'static str, + pub(crate) expected_status: u16, + pub(crate) expected_body: &'static str, + pub(crate) expected_cache: Option<&'static str>, +} + +pub(crate) struct UiPathTestCase { + pub(crate) description: &'static str, + pub(crate) path: &'static str, + pub(crate) expected_status: u16, + pub(crate) expected_body: &'static str, +} diff --git a/crates/sqlbuild-orchestration/tests/api/test_ui_assets.rs b/crates/sqlbuild-orchestration/tests/api/test_ui_assets.rs new file mode 100644 index 0000000000..afe9dd40f7 --- /dev/null +++ b/crates/sqlbuild-orchestration/tests/api/test_ui_assets.rs @@ -0,0 +1,337 @@ +//! The embedded UI answers client routes with its index and never shadows API or auth paths. + +use axum::Router; +use axum::routing::get; + +use crate::api::_helpers::resources::ui_assets::serve_ui; +use crate::api::models::UiAsset; +use crate::api::tests::helpers::raw_get; +use crate::api::tests::test_types::{UiAssetTestCase, UiPathTestCase}; +use crate::store::tests::store_helpers::run; + +#[test] +fn given_embedded_ui_when_requesting_pages_assets_and_api_paths_then_only_ui_paths_serve_ui_files() +{ + static ASSETS: &[UiAsset] = &[ + UiAsset { + path: "/index.html", + body: b"shell", + }, + UiAsset { + path: "/_app/immutable/entry/start.js", + body: b"start()", + }, + UiAsset { + path: "/theme.js", + body: b"theme()", + }, + ]; + let test_cases = [ + UiAssetTestCase { + description: "root serves the shell", + method: reqwest::Method::GET, + path: "/", + expected_status: 200, + expected_body: "shell", + expected_cache: Some("no-cache"), + }, + UiAssetTestCase { + description: "client route falls back to the shell", + method: reqwest::Method::GET, + path: "/runs/01a1b089", + expected_status: 200, + expected_body: "shell", + expected_cache: Some("no-cache"), + }, + UiAssetTestCase { + description: "hashed asset is immutable", + method: reqwest::Method::GET, + path: "/_app/immutable/entry/start.js", + expected_status: 200, + expected_body: "start()", + expected_cache: Some("public, max-age=31536000, immutable"), + }, + UiAssetTestCase { + description: "unhashed asset revalidates", + method: reqwest::Method::GET, + path: "/theme.js", + expected_status: 200, + expected_body: "theme()", + expected_cache: Some("no-cache"), + }, + UiAssetTestCase { + description: "missing file is not the shell", + method: reqwest::Method::GET, + path: "/_app/immutable/entry/gone.js", + expected_status: 404, + expected_body: "", + expected_cache: None, + }, + UiAssetTestCase { + description: "unknown API path is not the shell", + method: reqwest::Method::GET, + path: "/api/unknown", + expected_status: 404, + expected_body: "", + expected_cache: None, + }, + UiAssetTestCase { + description: "bare API prefix is not the shell", + method: reqwest::Method::GET, + path: "/api", + expected_status: 404, + expected_body: "", + expected_cache: None, + }, + UiAssetTestCase { + description: "unknown auth path is not the shell", + method: reqwest::Method::GET, + path: "/auth/unknown", + expected_status: 404, + expected_body: "", + expected_cache: None, + }, + UiAssetTestCase { + description: "writes never reach the shell", + method: reqwest::Method::POST, + path: "/runs", + expected_status: 404, + expected_body: "", + expected_cache: None, + }, + UiAssetTestCase { + description: "API routes keep precedence", + method: reqwest::Method::GET, + path: "/api/openapi.json", + expected_status: 200, + expected_body: "api", + expected_cache: None, + }, + ]; + run(async { + let application = serve_ui( + Router::new().route("/api/openapi.json", get(|| async { "api" })), + ASSETS, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("socket"); + let url = format!("http://{}", listener.local_addr().expect("address")); + let server = tokio::spawn(async move { axum::serve(listener, application).await }); + let client = reqwest::Client::new(); + for test_case in test_cases { + let response = client + .request(test_case.method, format!("{url}{}", test_case.path)) + .send() + .await + .expect("UI request"); + let headers = response.headers().clone(); + let header = |name: &str| { + headers + .get(name) + .map(|value| value.to_str().expect("ASCII header").to_owned()) + }; + assert_eq!( + response.status().as_u16(), + test_case.expected_status, + "{}", + test_case.description + ); + assert_eq!( + header("cache-control").as_deref(), + test_case.expected_cache, + "{}", + test_case.description + ); + assert_eq!( + header("content-security-policy").is_some(), + test_case.expected_cache.is_some(), + "{}", + test_case.description + ); + assert_eq!( + response.text().await.expect("body"), + test_case.expected_body, + "{}", + test_case.description + ); + } + server.abort(); + }); +} + +#[test] +fn given_embedded_ui_when_paths_carry_dot_segments_encodings_or_extra_slashes_then_they_never_reach_ui_files_or_bypass_api_routes() + { + static ASSETS: &[UiAsset] = &[ + UiAsset { + path: "/index.html", + body: b"shell", + }, + UiAsset { + path: "/theme.js", + body: b"theme()", + }, + ]; + let test_cases = [ + UiPathTestCase { + description: "parent segment after the API prefix", + path: "/api/../theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "parent segment inside a client route", + path: "/runs/../theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "current segment", + path: "/./theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "encoded parent segment", + path: "/runs/%2e%2e/theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "upper-case encoded parent segment", + path: "/%2E%2E/theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "encoded slash", + path: "/runs%2ftheme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "encoded backslash", + path: "/%5ctheme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "double slash before the API", + path: "//api/openapi.json", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "double slash before an asset", + path: "//theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "double slash inside a route", + path: "/runs//x", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "raw backslash", + path: "/runs\\..\\theme.js", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "raw backslash before the API", + path: "/\\api/openapi.json", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "fragment sent on an asset path", + path: "/theme.js#x", + expected_status: 200, + expected_body: "theme()", + }, + UiPathTestCase { + description: "bare API prefix", + path: "/api", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "API prefix with a trailing slash", + path: "/api/", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "auth prefix with a trailing slash", + path: "/auth/", + expected_status: 404, + expected_body: "", + }, + UiPathTestCase { + description: "a path that only starts with api is a client route", + path: "/apix", + expected_status: 200, + expected_body: "shell", + }, + UiPathTestCase { + description: "a path that only starts with auth is a client route", + path: "/authx", + expected_status: 200, + expected_body: "shell", + }, + UiPathTestCase { + description: "query string on an asset", + path: "/theme.js?v=1", + expected_status: 200, + expected_body: "theme()", + }, + UiPathTestCase { + description: "query naming the API on a client route", + path: "/runs?next=/api/x", + expected_status: 200, + expected_body: "shell", + }, + UiPathTestCase { + description: "API route keeps precedence", + path: "/api/openapi.json", + expected_status: 200, + expected_body: "api", + }, + UiPathTestCase { + description: "auth route keeps precedence", + path: "/auth/login", + expected_status: 200, + expected_body: "auth", + }, + ]; + run(async { + let application = serve_ui( + Router::new() + .route("/api/openapi.json", get(|| async { "api" })) + .route("/auth/login", get(|| async { "auth" })), + ASSETS, + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("socket"); + let address = listener.local_addr().expect("address").to_string(); + let server = tokio::spawn(async move { axum::serve(listener, application).await }); + for test_case in test_cases { + let target = address.clone(); + let (status, body) = + tokio::task::spawn_blocking(move || raw_get(&target, test_case.path)) + .await + .expect("raw request task"); + assert_eq!( + status, test_case.expected_status, + "{}", + test_case.description + ); + assert_eq!(body, test_case.expected_body, "{}", test_case.description); + } + server.abort(); + }); +} diff --git a/ui/src/app.html b/ui/src/app.html index b9a7abfef4..710c90734e 100644 --- a/ui/src/app.html +++ b/ui/src/app.html @@ -4,18 +4,8 @@ - %sveltekit.head% +
%sveltekit.body%
diff --git a/ui/src/lib/boot-failure.test.ts b/ui/src/lib/boot-failure.test.ts new file mode 100644 index 0000000000..77965d696d --- /dev/null +++ b/ui/src/lib/boot-failure.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '$lib/api/client'; +import { bootFailure } from './boot-failure'; + +describe('bootFailure', () => { + it('explains a missing bootstrap endpoint as missing person sign-in, not an outage', () => { + const failure = bootFailure(new ApiError('404 Not Found', 404)); + expect(failure.title).toBe('This server has no person sign-in configured'); + expect(failure.configuration).toBe(true); + }); + + it('keeps server errors as unreachable with their message', () => { + const failure = bootFailure(new ApiError('503 Service Unavailable', 503)); + expect(failure).toEqual({ title: "Can't reach the SQLBuild server", detail: '503 Service Unavailable', configuration: false }); + }); + + it('keeps network errors as unreachable', () => { + const failure = bootFailure(new TypeError('Failed to fetch')); + expect(failure.title).toBe("Can't reach the SQLBuild server"); + expect(failure.detail).toBe('Failed to fetch'); + }); +}); diff --git a/ui/src/lib/boot-failure.ts b/ui/src/lib/boot-failure.ts new file mode 100644 index 0000000000..85b24ede57 --- /dev/null +++ b/ui/src/lib/boot-failure.ts @@ -0,0 +1,30 @@ +import { ApiError } from '$lib/api/client'; + +export const SERVER_DOCS = 'https://sqlbuild.com/docs/cli/server/'; + +/** What the shell shows when the bootstrap fails. */ +export interface BootFailure { + title: string; + detail: string; + /** Retrying cannot help; the server's configuration must change. */ + configuration: boolean; +} + +/** + * A server without a person sign-in mode has no bootstrap endpoint, so a 404 there means the + * UI cannot be used on this server, not that the server is unreachable. + */ +export function bootFailure(error: unknown): BootFailure { + if (error instanceof ApiError && error.status === 404) { + return { + title: 'This server has no person sign-in configured', + detail: 'Start sqb server with password, OIDC or trusted-proxy sign-in to use this UI.', + configuration: true + }; + } + return { + title: "Can't reach the SQLBuild server", + detail: error instanceof Error ? error.message : String(error), + configuration: false + }; +} diff --git a/ui/src/lib/state/app.svelte.ts b/ui/src/lib/state/app.svelte.ts index 1aec50e41a..336731ad38 100644 --- a/ui/src/lib/state/app.svelte.ts +++ b/ui/src/lib/state/app.svelte.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { logout } from '$lib/api/http-client'; import { MockApiClient } from '$lib/api/mock/mock-client'; +import { bootFailure, type BootFailure } from '$lib/boot-failure'; import { ApiError, channelMatches, @@ -18,10 +19,6 @@ import { const SCOPE_KEY = 'sb-scope'; -function message(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - /** * App-wide state: the bootstrap, the selected location, and the * shell's live data (navigation counts, platform events, warehouse sync). @@ -29,7 +26,7 @@ function message(error: unknown): string { class AppState { client: ApiClient; bootstrap = $state(null); - bootError = $state(null); + bootError = $state(null); locationId = $state(''); /** Wall clock, ticking every second, for ages and elapsed times. */ now = $state(Date.now()); @@ -85,7 +82,7 @@ class AppState { } catch (error) { // A rejected session is already on its way to the login page. if (error instanceof ApiError && error.status === 401) return; - this.bootError = message(error); + this.bootError = bootFailure(error); return; } this.#restoreScope(); diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index af0d6916e7..4e29475548 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import AppSidebar from '$lib/components/app-sidebar.svelte'; import AppTopbar from '$lib/components/app-topbar.svelte'; import { pageAvailable } from '$lib/nav'; + import { SERVER_DOCS } from '$lib/boot-failure'; import { app } from '$lib/state/app.svelte'; let { children } = $props(); @@ -25,9 +26,13 @@ {:else if app.bootError}
-

Can't reach the SQLBuild server

-

{app.bootError}

- +

{app.bootError.title}

+

{app.bootError.detail}

+ {#if app.bootError.configuration} + Set up sign-in + {:else} + + {/if}
{:else if app.bootstrap && app.bootstrap.locations.length === 0} diff --git a/ui/static/theme.js b/ui/static/theme.js new file mode 100644 index 0000000000..bc769e669b --- /dev/null +++ b/ui/static/theme.js @@ -0,0 +1,6 @@ +// Apply the saved theme before first paint; the default is dark. +try { + if ((localStorage.getItem('sb-theme') || 'dark') === 'dark') document.documentElement.classList.add('dark'); +} catch (e) { + document.documentElement.classList.add('dark'); +} diff --git a/ui/svelte.config.js b/ui/svelte.config.js index 9f142e7774..e90e3492d2 100644 --- a/ui/svelte.config.js +++ b/ui/svelte.config.js @@ -11,6 +11,21 @@ const config = { alias: { $control: '../clients/typescript/src' }, // SPA mode: static build with client-side routing. There is no Node // server in production; the SQLBuild server serves the built assets. + // The served page carries this policy as a meta tag with the hash of SvelteKit's start + // script; sqb server adds frame-ancestors, which a meta tag cannot set. + csp: { + mode: 'hash', + directives: { + 'default-src': ['self'], + 'script-src': ['self'], + 'style-src': ['self', 'unsafe-inline'], + 'img-src': ['self', 'data:'], + 'connect-src': ['self'], + 'object-src': ['none'], + 'base-uri': ['none'], + 'form-action': ['self'] + } + }, adapter: adapter({ fallback: 'index.html', pages: 'build',