feat: add back built-in frontend hosting to the backend

This commit is contained in:
nelind
2026-03-06 20:21:10 +00:00
committed by Tangled
parent 898c6a2c6e
commit 34beff2553
18 changed files with 127 additions and 30 deletions
Generated
+28
View File
@@ -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"
+1 -1
View File
@@ -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"
+15
View File
@@ -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.
+2 -1
View File
@@ -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 }
+36 -3
View File
@@ -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 {
@@ -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<AppState>) -> Json<JwkSet> {
};
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),
))
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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/ {
+3 -3
View File
@@ -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/ {
@@ -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"],
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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/`;
+1 -1
View File
@@ -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',
@@ -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`,
);
});
});
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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/ {
+3 -3
View File
@@ -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"):