From 34beff25535aa27a9e0e3382420c0260d12998cb Mon Sep 17 00:00:00 2001 From: nelind Date: Sun, 22 Feb 2026 23:38:50 +0100 Subject: [PATCH] feat: add back built-in frontend hosting to the backend --- Cargo.lock | 28 +++++++++++++ Cargo.toml | 2 +- crates/tranquil-config/src/lib.rs | 15 +++++++ crates/tranquil-pds/Cargo.toml | 3 +- crates/tranquil-pds/src/lib.rs | 39 +++++++++++++++++-- .../src/oauth/endpoints/metadata.rs | 20 ++++++++++ deploy/nginx/nginx-quadlet.conf | 2 +- docs/install-debian.md | 2 +- frontend/nginx-quadlet.conf | 6 +-- frontend/nginx.conf | 6 +-- ...tadata.json => oauth-client-metadata.json} | 10 ++--- frontend/src/lib/migration/atproto-client.ts | 2 +- frontend/src/lib/oauth.ts | 2 +- frontend/src/routes/ActAs.svelte | 2 +- .../tests/migration/atproto-client.test.ts | 6 +-- module.nix | 2 +- nginx.frontend.conf | 4 +- test.nix | 6 +-- 18 files changed, 127 insertions(+), 30 deletions(-) rename frontend/public/{oauth/client-metadata.json => oauth-client-metadata.json} (57%) diff --git a/Cargo.lock b/Cargo.lock index c07b64f..b67615e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2743,6 +2743,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3613,6 +3619,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5976,13 +5992,19 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6414,6 +6436,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 4de6567..1be3a30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,7 @@ tokio-util = "0.7.18" tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } totp-rs = { version = "5", features = ["qr"] } tower = "0.5" -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.6", features = ["fs", "cors"] } tower-layer = "0.3" tracing = "0.1" tracing-subscriber = "0.3" diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 18a86c1..4cbffae 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -96,6 +96,9 @@ pub struct TranquilConfig { #[config(nested)] pub server: ServerConfig, + #[config(nested)] + pub frontend: FrontendConfig, + #[config(nested)] pub database: DatabaseConfig, @@ -482,6 +485,18 @@ impl ServerConfig { } } +#[derive(Debug, Config)] +pub struct FrontendConfig { + /// Whether to enable the built in serving of the frontend. + #[config(env = "FRONTEND_ENABLED", default = true)] + pub enabled: bool, + + /// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to + /// the frontend hostname replaced by the configured frontend hostname. + #[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")] + pub dir: String, +} + #[derive(Debug, Config)] pub struct DatabaseConfig { /// PostgreSQL connection URL. diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index adbb578..5591bef 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -83,11 +83,12 @@ aws-config = { workspace = true, optional = true } aws-sdk-s3 = { workspace = true, optional = true } [features] -default = ["s3", "valkey"] +default = ["frontend", "s3", "valkey"] external-infra = [] s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"] s3 = ["s3-storage"] valkey = ["tranquil-cache/valkey", "dep:redis"] +frontend = [] [dev-dependencies] ciborium = { workspace = true } diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 19d0bd1..92ea00b 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -39,7 +39,10 @@ use http::StatusCode; use serde_json::json; use state::AppState; use tower::ServiceBuilder; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::{ + cors::{Any, CorsLayer}, + services::{ServeDir, ServeFile}, +}; pub use tranquil_db_traits::AccountStatus; pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; @@ -650,7 +653,9 @@ pub fn app(state: AppState) -> Router { get(oauth::endpoints::oauth_authorization_server), ); - Router::new() + if cfg!(feature = "frontend") {} + + let router = Router::new() .nest_service("/xrpc", xrpc_service) .nest("/oauth", oauth_router) .nest("/.well-known", well_known_router) @@ -695,7 +700,35 @@ pub fn app(state: AppState) -> Router { util::HEADER_ATPROTO_CONTENT_LABELERS, ]), ) - .with_state(state) + .with_state(state); + + if cfg!(feature = "frontend") && tranquil_config::get().frontend.enabled { + let frontend_dir = &tranquil_config::get().frontend.dir; + let index_path = format!("{}/index.html", frontend_dir); + let homepage_path = format!("{}/homepage.html", frontend_dir); + + let homepage_exists = std::path::Path::new(&homepage_path).exists(); + let homepage_file = if homepage_exists { + homepage_path + } else { + index_path.clone() + }; + + let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); + + let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); + + return router + .route( + "/oauth-client-metadata.json", + get(oauth::endpoints::frontend_client_metadata), + ) + .route_service("/", ServeFile::new(&homepage_file)) + .nest("/app", spa_router) + .fallback_service(serve_dir); + } + + router } async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response { diff --git a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs index 8c74278..1dbdf53 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/metadata.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/metadata.rs @@ -1,6 +1,9 @@ +use std::fmt::Debug; + use crate::oauth::jwks::{JwkSet, create_jwk_set}; use crate::state::AppState; use axum::{Json, extract::State}; +use http::{HeaderName, header}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] @@ -140,3 +143,20 @@ pub async fn oauth_jwks(State(_state): State) -> Json { }; Json(create_jwk_set(vec![server_key])) } + +pub async fn frontend_client_metadata() +-> axum::response::Result<([(HeaderName, &'static str); 1], String)> { + let frontend_hostname = &tranquil_config::get().server.hostname; + let metadata_string = tokio::fs::read_to_string(format!( + "{}/oauth-client-metadata.json", + &tranquil_config::get().frontend.dir + )) + .await + // TODO: consider if a better conversion can be done here. + .map_err(|io_err| io_err.to_string())?; + + Ok(( + [(header::CONTENT_TYPE, "application/json")], + metadata_string.replace("__FRONTEND_HOSTNAME__", frontend_hostname), + )) +} diff --git a/deploy/nginx/nginx-quadlet.conf b/deploy/nginx/nginx-quadlet.conf index 10629c0..fde9192 100644 --- a/deploy/nginx/nginx-quadlet.conf +++ b/deploy/nginx/nginx-quadlet.conf @@ -62,7 +62,7 @@ http { proxy_request_buffering off; } - location = /oauth/client-metadata.json { + location = /oauth-client-metadata.json { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs/install-debian.md b/docs/install-debian.md index c237c89..00d2400 100644 --- a/docs/install-debian.md +++ b/docs/install-debian.md @@ -179,7 +179,7 @@ server { proxy_request_buffering off; } - location = /oauth/client-metadata.json { + location = /oauth-client-metadata.json { root /var/www/tranquil-pds; default_type application/json; sub_filter_once off; diff --git a/frontend/nginx-quadlet.conf b/frontend/nginx-quadlet.conf index 84a39e4..a69e667 100644 --- a/frontend/nginx-quadlet.conf +++ b/frontend/nginx-quadlet.conf @@ -10,12 +10,12 @@ server { gzip_vary on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - location = /oauth/client-metadata.json { + location = /oauth-client-metadata.json { default_type application/json; sub_filter_once off; sub_filter_types application/json; - sub_filter '__PDS_HOSTNAME__' $host; - try_files /oauth/client-metadata.json =404; + sub_filter '__FRONTEND_HOSTNAME__' $host; + try_files /oauth-client-metadata.json =404; } location /assets/ { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d8363a2..1a805f4 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -10,12 +10,12 @@ server { gzip_vary on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - location = /oauth/client-metadata.json { + location = /oauth-client-metadata.json { default_type application/json; sub_filter_once off; sub_filter_types application/json; - sub_filter '__PDS_HOSTNAME__' $host; - try_files /oauth/client-metadata.json =404; + sub_filter '__FRONTEND_HOSTNAME__' $host; + try_files /oauth-client-metadata.json =404; } location /assets/ { diff --git a/frontend/public/oauth/client-metadata.json b/frontend/public/oauth-client-metadata.json similarity index 57% rename from frontend/public/oauth/client-metadata.json rename to frontend/public/oauth-client-metadata.json index 76b2ba4..b0693fe 100644 --- a/frontend/public/oauth/client-metadata.json +++ b/frontend/public/oauth-client-metadata.json @@ -1,10 +1,10 @@ { - "client_id": "https://__PDS_HOSTNAME__/oauth/client-metadata.json", - "client_name": "PDS Account Manager", - "client_uri": "https://__PDS_HOSTNAME__", + "client_id": "https://__FRONTEND_HOSTNAME__/oauth-client-metadata.json", + "client_name": "Tranquil PDS Account Manager", + "client_uri": "https://__FRONTEND_HOSTNAME__", "redirect_uris": [ - "https://__PDS_HOSTNAME__/app/", - "https://__PDS_HOSTNAME__/app/migrate" + "https://__FRONTEND_HOSTNAME__/app/", + "https://__FRONTEND_HOSTNAME__/app/migrate" ], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 8aee4a3..6d42d97 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -1055,7 +1055,7 @@ export function createLocalClient(): AtprotoClient { } export function getMigrationOAuthClientId(): string { - return `${globalThis.location.origin}/oauth/client-metadata.json`; + return `${globalThis.location.origin}/oauth-client-metadata.json`; } export function getMigrationOAuthRedirectUri(): string { diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index 79d88a8..fdbadda 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -14,7 +14,7 @@ const SCOPES = [ ].join(" "); const CLIENT_ID = !(import.meta.env.DEV) - ? `${globalThis.location.origin}/oauth/client-metadata.json` + ? `${globalThis.location.origin}/oauth-client-metadata.json` : `http://localhost/?scope=${SCOPES}`; const REDIRECT_URI = `${globalThis.location.origin}/app/`; diff --git a/frontend/src/routes/ActAs.svelte b/frontend/src/routes/ActAs.svelte index a594930..f83fc6d 100644 --- a/frontend/src/routes/ActAs.svelte +++ b/frontend/src/routes/ActAs.svelte @@ -51,7 +51,7 @@ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ - client_id: `${hostname}/oauth/client-metadata.json`, + client_id: `${hostname}/oauth-client-metadata.json`, redirect_uri: `${hostname}/app/`, response_type: 'code', scope: 'atproto', diff --git a/frontend/src/tests/migration/atproto-client.test.ts b/frontend/src/tests/migration/atproto-client.test.ts index cf2b076..cd2202d 100644 --- a/frontend/src/tests/migration/atproto-client.test.ts +++ b/frontend/src/tests/migration/atproto-client.test.ts @@ -166,7 +166,7 @@ describe("migration/atproto-client", () => { it("builds authorization URL with required parameters", () => { const url = buildOAuthAuthorizationUrl(mockMetadata, { - clientId: "https://example.com/oauth/client-metadata.json", + clientId: "https://example.com/oauth-client-metadata.json", redirectUri: "https://example.com/migrate", codeChallenge: "abc123", state: "state123", @@ -177,7 +177,7 @@ describe("migration/atproto-client", () => { expect(parsed.pathname).toBe("/oauth/authorize"); expect(parsed.searchParams.get("response_type")).toBe("code"); expect(parsed.searchParams.get("client_id")).toBe( - "https://example.com/oauth/client-metadata.json", + "https://example.com/oauth-client-metadata.json", ); expect(parsed.searchParams.get("redirect_uri")).toBe( "https://example.com/migrate", @@ -256,7 +256,7 @@ describe("migration/atproto-client", () => { it("returns client metadata URL based on origin", () => { const clientId = getMigrationOAuthClientId(); expect(clientId).toBe( - `${globalThis.location.origin}/oauth/client-metadata.json`, + `${globalThis.location.origin}/oauth-client-metadata.json`, ); }); }); diff --git a/module.nix b/module.nix index 8f5b506..5fd2aa2 100644 --- a/module.nix +++ b/module.nix @@ -261,7 +261,7 @@ in { } (lib.optionalAttrs (cfg.frontend.package != null) { - "= /oauth/client-metadata.json" = { + "= /oauth-client-metadata.json" = { root = "${cfg.frontend.package}"; extraConfig = '' default_type application/json; diff --git a/nginx.frontend.conf b/nginx.frontend.conf index 6b2467e..52ed905 100644 --- a/nginx.frontend.conf +++ b/nginx.frontend.conf @@ -92,14 +92,14 @@ http { proxy_request_buffering off; } - location = /oauth/client-metadata.json { + location = /oauth-client-metadata.json { proxy_pass http://frontend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Accept-Encoding ""; sub_filter_once off; sub_filter_types application/json; - sub_filter '__PDS_HOSTNAME__' $host; + sub_filter '__FRONTEND_HOSTNAME__' $host; } location /oauth/ { diff --git a/test.nix b/test.nix index 9899bac..4ccaa0b 100644 --- a/test.nix +++ b/test.nix @@ -147,10 +147,10 @@ pkgs.testers.nixosTest { code = http_status("/xrpc/_health", host="alice.pds.test") assert code == "200", f"subdomain routing failed: {code}" - with subtest("client-metadata.json served with host substitution"): - meta_raw = http_get("/oauth/client-metadata.json") + with subtest("oauth-client-metadata.json served with host substitution"): + meta_raw = http_get("/oauth-client-metadata.json") meta = json.loads(meta_raw) - assert "client_id" in meta, f"no client_id in client-metadata: {meta}" + assert "client_id" in meta, f"no client_id in oauth-client-metadata: {meta}" assert "pds.test" in meta_raw, "host substitution did not apply" with subtest("static assets location exists"):