Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

feat: add in-backend frontend hosting back #32

merged opened by nel.pet targeting main from feat/built-in-frontend-server
Labels

None yet.

assignee
Participants 1
AT URI
at://did:plc:h5wsnqetncv6lu2weom35lg2/sh.tangled.repo.pull/3mfywro7uzd22
+127 -30
Diff #4
+28
Cargo.lock
··· 2743 2743 "pin-project-lite", 2744 2744 ] 2745 2745 2746 + [[package]] 2747 + name = "http-range-header" 2748 + version = "0.4.2" 2749 + source = "registry+https://github.com/rust-lang/crates.io-index" 2750 + checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" 2751 + 2746 2752 [[package]] 2747 2753 name = "httparse" 2748 2754 version = "1.10.1" ··· 3613 3619 source = "registry+https://github.com/rust-lang/crates.io-index" 3614 3620 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 3615 3621 3622 + [[package]] 3623 + name = "mime_guess" 3624 + version = "2.0.5" 3625 + source = "registry+https://github.com/rust-lang/crates.io-index" 3626 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 3627 + dependencies = [ 3628 + "mime", 3629 + "unicase", 3630 + ] 3631 + 3616 3632 [[package]] 3617 3633 name = "minimal-lexical" 3618 3634 version = "0.2.1" ··· 5976 5992 "http 1.4.0", 5977 5993 "http-body 1.0.1", 5978 5994 "http-body-util", 5995 + "http-range-header", 5996 + "httpdate", 5979 5997 "iri-string", 5998 + "mime", 5999 + "mime_guess", 6000 + "percent-encoding", 5980 6001 "pin-project-lite", 5981 6002 "tokio", 5982 6003 "tokio-util", 5983 6004 "tower", 5984 6005 "tower-layer", 5985 6006 "tower-service", 6007 + "tracing", 5986 6008 ] 5987 6009 5988 6010 [[package]] ··· 6414 6436 source = "registry+https://github.com/rust-lang/crates.io-index" 6415 6437 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 6416 6438 6439 + [[package]] 6440 + name = "unicase" 6441 + version = "2.9.0" 6442 + source = "registry+https://github.com/rust-lang/crates.io-index" 6443 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 6444 + 6417 6445 [[package]] 6418 6446 name = "unicode-bidi" 6419 6447 version = "0.3.18"
+1 -1
Cargo.toml
··· 100 100 tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } 101 101 totp-rs = { version = "5", features = ["qr"] } 102 102 tower = "0.5" 103 - tower-http = { version = "0.6", features = ["cors"] } 103 + tower-http = { version = "0.6", features = ["fs", "cors"] } 104 104 tower-layer = "0.3" 105 105 tracing = "0.1" 106 106 tracing-subscriber = "0.3"
+15
crates/tranquil-config/src/lib.rs
··· 96 96 #[config(nested)] 97 97 pub server: ServerConfig, 98 98 99 + #[config(nested)] 100 + pub frontend: FrontendConfig, 101 + 99 102 #[config(nested)] 100 103 pub database: DatabaseConfig, 101 104 ··· 482 485 } 483 486 } 484 487 488 + #[derive(Debug, Config)] 489 + pub struct FrontendConfig { 490 + /// Whether to enable the built in serving of the frontend. 491 + #[config(env = "FRONTEND_ENABLED", default = true)] 492 + pub enabled: bool, 493 + 494 + /// Directory to serve as the frontend. The oauth_client_metadata.json will have any references to 495 + /// the frontend hostname replaced by the configured frontend hostname. 496 + #[config(env = "FRONTEND_DIR", default = "/var/lib/tranquil-pds/frontend")] 497 + pub dir: String, 498 + } 499 + 485 500 #[derive(Debug, Config)] 486 501 pub struct DatabaseConfig { 487 502 /// PostgreSQL connection URL.
+2 -1
crates/tranquil-pds/Cargo.toml
··· 83 83 aws-sdk-s3 = { workspace = true, optional = true } 84 84 85 85 [features] 86 - default = ["s3", "valkey"] 86 + default = ["frontend", "s3", "valkey"] 87 87 external-infra = [] 88 88 s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"] 89 89 s3 = ["s3-storage"] 90 90 valkey = ["tranquil-cache/valkey", "dep:redis"] 91 + frontend = [] 91 92 92 93 [dev-dependencies] 93 94 ciborium = { workspace = true }
+36 -3
crates/tranquil-pds/src/lib.rs
··· 39 39 use serde_json::json; 40 40 use state::AppState; 41 41 use tower::ServiceBuilder; 42 - use tower_http::cors::{Any, CorsLayer}; 42 + use tower_http::{ 43 + cors::{Any, CorsLayer}, 44 + services::{ServeDir, ServeFile}, 45 + }; 43 46 pub use tranquil_db_traits::AccountStatus; 44 47 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 45 48 ··· 650 653 get(oauth::endpoints::oauth_authorization_server), 651 654 ); 652 655 653 - Router::new() 656 + if cfg!(feature = "frontend") {} 657 + 658 + let router = Router::new() 654 659 .nest_service("/xrpc", xrpc_service) 655 660 .nest("/oauth", oauth_router) 656 661 .nest("/.well-known", well_known_router) ··· 695 700 util::HEADER_ATPROTO_CONTENT_LABELERS, 696 701 ]), 697 702 ) 698 - .with_state(state) 703 + .with_state(state); 704 + 705 + if cfg!(feature = "frontend") && tranquil_config::get().frontend.enabled { 706 + let frontend_dir = &tranquil_config::get().frontend.dir; 707 + let index_path = format!("{}/index.html", frontend_dir); 708 + let homepage_path = format!("{}/homepage.html", frontend_dir); 709 + 710 + let homepage_exists = std::path::Path::new(&homepage_path).exists(); 711 + let homepage_file = if homepage_exists { 712 + homepage_path 713 + } else { 714 + index_path.clone() 715 + }; 716 + 717 + let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); 718 + 719 + let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); 720 + 721 + return router 722 + .route( 723 + "/oauth-client-metadata.json", 724 + get(oauth::endpoints::frontend_client_metadata), 725 + ) 726 + .route_service("/", ServeFile::new(&homepage_file)) 727 + .nest("/app", spa_router) 728 + .fallback_service(serve_dir); 729 + } 730 + 731 + router 699 732 } 700 733 701 734 async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
+20
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
··· 1 + use std::fmt::Debug; 2 + 1 3 use crate::oauth::jwks::{JwkSet, create_jwk_set}; 2 4 use crate::state::AppState; 3 5 use axum::{Json, extract::State}; 6 + use http::{HeaderName, header}; 4 7 use serde::{Deserialize, Serialize}; 5 8 6 9 #[derive(Debug, Serialize, Deserialize)] ··· 140 143 }; 141 144 Json(create_jwk_set(vec![server_key])) 142 145 } 146 + 147 + pub async fn frontend_client_metadata() 148 + -> axum::response::Result<([(HeaderName, &'static str); 1], String)> { 149 + let frontend_hostname = &tranquil_config::get().server.hostname; 150 + let metadata_string = tokio::fs::read_to_string(format!( 151 + "{}/oauth-client-metadata.json", 152 + &tranquil_config::get().frontend.dir 153 + )) 154 + .await 155 + // TODO: consider if a better conversion can be done here. 156 + .map_err(|io_err| io_err.to_string())?; 157 + 158 + Ok(( 159 + [(header::CONTENT_TYPE, "application/json")], 160 + metadata_string.replace("__FRONTEND_HOSTNAME__", frontend_hostname), 161 + )) 162 + }
+1 -1
deploy/nginx/nginx-quadlet.conf
··· 62 62 proxy_request_buffering off; 63 63 } 64 64 65 - location = /oauth/client-metadata.json { 65 + location = /oauth-client-metadata.json { 66 66 proxy_pass http://127.0.0.1:8080; 67 67 proxy_http_version 1.1; 68 68 proxy_set_header Host $host;
+1 -1
docs/install-debian.md
··· 179 179 proxy_request_buffering off; 180 180 } 181 181 182 - location = /oauth/client-metadata.json { 182 + location = /oauth-client-metadata.json { 183 183 root /var/www/tranquil-pds; 184 184 default_type application/json; 185 185 sub_filter_once off;
+3 -3
frontend/nginx-quadlet.conf
··· 10 10 gzip_vary on; 11 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 12 13 - location = /oauth/client-metadata.json { 13 + location = /oauth-client-metadata.json { 14 14 default_type application/json; 15 15 sub_filter_once off; 16 16 sub_filter_types application/json; 17 - sub_filter '__PDS_HOSTNAME__' $host; 18 - try_files /oauth/client-metadata.json =404; 17 + sub_filter '__FRONTEND_HOSTNAME__' $host; 18 + try_files /oauth-client-metadata.json =404; 19 19 } 20 20 21 21 location /assets/ {
+3 -3
frontend/nginx.conf
··· 10 10 gzip_vary on; 11 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 12 13 - location = /oauth/client-metadata.json { 13 + location = /oauth-client-metadata.json { 14 14 default_type application/json; 15 15 sub_filter_once off; 16 16 sub_filter_types application/json; 17 - sub_filter '__PDS_HOSTNAME__' $host; 18 - try_files /oauth/client-metadata.json =404; 17 + sub_filter '__FRONTEND_HOSTNAME__' $host; 18 + try_files /oauth-client-metadata.json =404; 19 19 } 20 20 21 21 location /assets/ {
+5 -5
frontend/public/oauth/client-metadata.json frontend/public/oauth-client-metadata.json
··· 1 1 { 2 - "client_id": "https://__PDS_HOSTNAME__/oauth/client-metadata.json", 3 - "client_name": "PDS Account Manager", 4 - "client_uri": "https://__PDS_HOSTNAME__", 2 + "client_id": "https://__FRONTEND_HOSTNAME__/oauth-client-metadata.json", 3 + "client_name": "Tranquil PDS Account Manager", 4 + "client_uri": "https://__FRONTEND_HOSTNAME__", 5 5 "redirect_uris": [ 6 - "https://__PDS_HOSTNAME__/app/", 7 - "https://__PDS_HOSTNAME__/app/migrate" 6 + "https://__FRONTEND_HOSTNAME__/app/", 7 + "https://__FRONTEND_HOSTNAME__/app/migrate" 8 8 ], 9 9 "grant_types": ["authorization_code", "refresh_token"], 10 10 "response_types": ["code"],
+1 -1
frontend/src/lib/migration/atproto-client.ts
··· 1055 1055 } 1056 1056 1057 1057 export function getMigrationOAuthClientId(): string { 1058 - return `${globalThis.location.origin}/oauth/client-metadata.json`; 1058 + return `${globalThis.location.origin}/oauth-client-metadata.json`; 1059 1059 } 1060 1060 1061 1061 export function getMigrationOAuthRedirectUri(): string {
+1 -1
frontend/src/lib/oauth.ts
··· 14 14 ].join(" "); 15 15 16 16 const CLIENT_ID = !(import.meta.env.DEV) 17 - ? `${globalThis.location.origin}/oauth/client-metadata.json` 17 + ? `${globalThis.location.origin}/oauth-client-metadata.json` 18 18 : `http://localhost/?scope=${SCOPES}`; 19 19 20 20 const REDIRECT_URI = `${globalThis.location.origin}/app/`;
+1 -1
frontend/src/routes/ActAs.svelte
··· 51 51 method: 'POST', 52 52 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 53 53 body: new URLSearchParams({ 54 - client_id: `${hostname}/oauth/client-metadata.json`, 54 + client_id: `${hostname}/oauth-client-metadata.json`, 55 55 redirect_uri: `${hostname}/app/`, 56 56 response_type: 'code', 57 57 scope: 'atproto',
+3 -3
frontend/src/tests/migration/atproto-client.test.ts
··· 166 166 167 167 it("builds authorization URL with required parameters", () => { 168 168 const url = buildOAuthAuthorizationUrl(mockMetadata, { 169 - clientId: "https://example.com/oauth/client-metadata.json", 169 + clientId: "https://example.com/oauth-client-metadata.json", 170 170 redirectUri: "https://example.com/migrate", 171 171 codeChallenge: "abc123", 172 172 state: "state123", ··· 177 177 expect(parsed.pathname).toBe("/oauth/authorize"); 178 178 expect(parsed.searchParams.get("response_type")).toBe("code"); 179 179 expect(parsed.searchParams.get("client_id")).toBe( 180 - "https://example.com/oauth/client-metadata.json", 180 + "https://example.com/oauth-client-metadata.json", 181 181 ); 182 182 expect(parsed.searchParams.get("redirect_uri")).toBe( 183 183 "https://example.com/migrate", ··· 256 256 it("returns client metadata URL based on origin", () => { 257 257 const clientId = getMigrationOAuthClientId(); 258 258 expect(clientId).toBe( 259 - `${globalThis.location.origin}/oauth/client-metadata.json`, 259 + `${globalThis.location.origin}/oauth-client-metadata.json`, 260 260 ); 261 261 }); 262 262 });
+1 -1
module.nix
··· 261 261 } 262 262 263 263 (lib.optionalAttrs (cfg.frontend.package != null) { 264 - "= /oauth/client-metadata.json" = { 264 + "= /oauth-client-metadata.json" = { 265 265 root = "${cfg.frontend.package}"; 266 266 extraConfig = '' 267 267 default_type application/json;
+2 -2
nginx.frontend.conf
··· 92 92 proxy_request_buffering off; 93 93 } 94 94 95 - location = /oauth/client-metadata.json { 95 + location = /oauth-client-metadata.json { 96 96 proxy_pass http://frontend; 97 97 proxy_http_version 1.1; 98 98 proxy_set_header Host $host; 99 99 proxy_set_header Accept-Encoding ""; 100 100 sub_filter_once off; 101 101 sub_filter_types application/json; 102 - sub_filter '__PDS_HOSTNAME__' $host; 102 + sub_filter '__FRONTEND_HOSTNAME__' $host; 103 103 } 104 104 105 105 location /oauth/ {
+3 -3
test.nix
··· 147 147 code = http_status("/xrpc/_health", host="alice.pds.test") 148 148 assert code == "200", f"subdomain routing failed: {code}" 149 149 150 - with subtest("client-metadata.json served with host substitution"): 151 - meta_raw = http_get("/oauth/client-metadata.json") 150 + with subtest("oauth-client-metadata.json served with host substitution"): 151 + meta_raw = http_get("/oauth-client-metadata.json") 152 152 meta = json.loads(meta_raw) 153 - assert "client_id" in meta, f"no client_id in client-metadata: {meta}" 153 + assert "client_id" in meta, f"no client_id in oauth-client-metadata: {meta}" 154 154 assert "pds.test" in meta_raw, "host substitution did not apply" 155 155 156 156 with subtest("static assets location exists"):

History

5 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat: add back built-in frontend hosting to the backend
expand 0 comments
pull request successfully merged
1 commit
expand
feat: add back built-in frontend hosting to the backend
expand 0 comments
1 commit
expand
feat: add back built-in frontend hosting to the backend
expand 0 comments
1 commit
expand
feat: add back built-in frontend hosting to the backend
expand 0 comments
nel.pet submitted #0
1 commit
expand
feat: add in-backend frontend hosting back
expand 0 comments