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 #1
+28
Cargo.lock
··· 2743 "pin-project-lite", 2744 ] 2745 2746 [[package]] 2747 name = "httparse" 2748 version = "1.10.1" ··· 3613 source = "registry+https://github.com/rust-lang/crates.io-index" 3614 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 3615 3616 [[package]] 3617 name = "minimal-lexical" 3618 version = "0.2.1" ··· 5976 "http 1.4.0", 5977 "http-body 1.0.1", 5978 "http-body-util", 5979 "iri-string", 5980 "pin-project-lite", 5981 "tokio", 5982 "tokio-util", 5983 "tower", 5984 "tower-layer", 5985 "tower-service", 5986 ] 5987 5988 [[package]] ··· 6414 source = "registry+https://github.com/rust-lang/crates.io-index" 6415 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 6416 6417 [[package]] 6418 name = "unicode-bidi" 6419 version = "0.3.18"
··· 2743 "pin-project-lite", 2744 ] 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 + 2752 [[package]] 2753 name = "httparse" 2754 version = "1.10.1" ··· 3619 source = "registry+https://github.com/rust-lang/crates.io-index" 3620 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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 + 3632 [[package]] 3633 name = "minimal-lexical" 3634 version = "0.2.1" ··· 5992 "http 1.4.0", 5993 "http-body 1.0.1", 5994 "http-body-util", 5995 + "http-range-header", 5996 + "httpdate", 5997 "iri-string", 5998 + "mime", 5999 + "mime_guess", 6000 + "percent-encoding", 6001 "pin-project-lite", 6002 "tokio", 6003 "tokio-util", 6004 "tower", 6005 "tower-layer", 6006 "tower-service", 6007 + "tracing", 6008 ] 6009 6010 [[package]] ··· 6436 source = "registry+https://github.com/rust-lang/crates.io-index" 6437 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 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 + 6445 [[package]] 6446 name = "unicode-bidi" 6447 version = "0.3.18"
+1 -1
Cargo.toml
··· 100 tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } 101 totp-rs = { version = "5", features = ["qr"] } 102 tower = "0.5" 103 - tower-http = { version = "0.6", features = ["cors"] } 104 tower-layer = "0.3" 105 tracing = "0.1" 106 tracing-subscriber = "0.3"
··· 100 tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } 101 totp-rs = { version = "5", features = ["qr"] } 102 tower = "0.5" 103 + tower-http = { version = "0.6", features = ["fs", "cors"] } 104 tower-layer = "0.3" 105 tracing = "0.1" 106 tracing-subscriber = "0.3"
+15
crates/tranquil-config/src/lib.rs
··· 96 #[config(nested)] 97 pub server: ServerConfig, 98 99 #[config(nested)] 100 pub database: DatabaseConfig, 101 ··· 482 } 483 } 484 485 #[derive(Debug, Config)] 486 pub struct DatabaseConfig { 487 /// PostgreSQL connection URL.
··· 96 #[config(nested)] 97 pub server: ServerConfig, 98 99 + #[config(nested)] 100 + pub frontend: FrontendConfig, 101 + 102 #[config(nested)] 103 pub database: DatabaseConfig, 104 ··· 485 } 486 } 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 + 500 #[derive(Debug, Config)] 501 pub struct DatabaseConfig { 502 /// PostgreSQL connection URL.
+2 -1
crates/tranquil-pds/Cargo.toml
··· 83 aws-sdk-s3 = { workspace = true, optional = true } 84 85 [features] 86 - default = ["s3", "valkey"] 87 external-infra = [] 88 s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"] 89 s3 = ["s3-storage"] 90 valkey = ["tranquil-cache/valkey", "dep:redis"] 91 92 [dev-dependencies] 93 ciborium = { workspace = true }
··· 83 aws-sdk-s3 = { workspace = true, optional = true } 84 85 [features] 86 + default = ["frontend", "s3", "valkey"] 87 external-infra = [] 88 s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"] 89 s3 = ["s3-storage"] 90 valkey = ["tranquil-cache/valkey", "dep:redis"] 91 + frontend = [] 92 93 [dev-dependencies] 94 ciborium = { workspace = true }
+36 -3
crates/tranquil-pds/src/lib.rs
··· 39 use serde_json::json; 40 use state::AppState; 41 use tower::ServiceBuilder; 42 - use tower_http::cors::{Any, CorsLayer}; 43 pub use tranquil_db_traits::AccountStatus; 44 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 45 ··· 640 get(oauth::endpoints::oauth_authorization_server), 641 ); 642 643 - Router::new() 644 .nest_service("/xrpc", xrpc_service) 645 .nest("/oauth", oauth_router) 646 .nest("/.well-known", well_known_router) ··· 685 util::HEADER_ATPROTO_CONTENT_LABELERS, 686 ]), 687 ) 688 - .with_state(state) 689 } 690 691 async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
··· 39 use serde_json::json; 40 use state::AppState; 41 use tower::ServiceBuilder; 42 + use tower_http::{ 43 + cors::{Any, CorsLayer}, 44 + services::{ServeDir, ServeFile}, 45 + }; 46 pub use tranquil_db_traits::AccountStatus; 47 pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; 48 ··· 643 get(oauth::endpoints::oauth_authorization_server), 644 ); 645 646 + if cfg!(feature = "frontend") {} 647 + 648 + let router = Router::new() 649 .nest_service("/xrpc", xrpc_service) 650 .nest("/oauth", oauth_router) 651 .nest("/.well-known", well_known_router) ··· 690 util::HEADER_ATPROTO_CONTENT_LABELERS, 691 ]), 692 ) 693 + .with_state(state); 694 + 695 + if cfg!(feature = "frontend") && tranquil_config::get().frontend.enabled { 696 + let frontend_dir = &tranquil_config::get().frontend.dir; 697 + let index_path = format!("{}/index.html", frontend_dir); 698 + let homepage_path = format!("{}/homepage.html", frontend_dir); 699 + 700 + let homepage_exists = std::path::Path::new(&homepage_path).exists(); 701 + let homepage_file = if homepage_exists { 702 + homepage_path 703 + } else { 704 + index_path.clone() 705 + }; 706 + 707 + let spa_router = Router::new().fallback_service(ServeFile::new(&index_path)); 708 + 709 + let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); 710 + 711 + return router 712 + .route( 713 + "/oauth-client-metadata.json", 714 + get(oauth::endpoints::frontend_client_metadata), 715 + ) 716 + .route_service("/", ServeFile::new(&homepage_file)) 717 + .nest("/app", spa_router) 718 + .fallback_service(serve_dir); 719 + } 720 + 721 + router 722 } 723 724 async fn rewrite_422_to_400(response: axum::response::Response) -> axum::response::Response {
+20
crates/tranquil-pds/src/oauth/endpoints/metadata.rs
··· 1 use crate::oauth::jwks::{JwkSet, create_jwk_set}; 2 use crate::state::AppState; 3 use axum::{Json, extract::State}; 4 use serde::{Deserialize, Serialize}; 5 6 #[derive(Debug, Serialize, Deserialize)] ··· 140 }; 141 Json(create_jwk_set(vec![server_key])) 142 }
··· 1 + use std::fmt::Debug; 2 + 3 use crate::oauth::jwks::{JwkSet, create_jwk_set}; 4 use crate::state::AppState; 5 use axum::{Json, extract::State}; 6 + use http::{HeaderName, header}; 7 use serde::{Deserialize, Serialize}; 8 9 #[derive(Debug, Serialize, Deserialize)] ··· 143 }; 144 Json(create_jwk_set(vec![server_key])) 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 proxy_request_buffering off; 63 } 64 65 - location = /oauth/client-metadata.json { 66 proxy_pass http://127.0.0.1:8080; 67 proxy_http_version 1.1; 68 proxy_set_header Host $host;
··· 62 proxy_request_buffering off; 63 } 64 65 + location = /oauth-client-metadata.json { 66 proxy_pass http://127.0.0.1:8080; 67 proxy_http_version 1.1; 68 proxy_set_header Host $host;
+1 -1
docs/install-debian.md
··· 179 proxy_request_buffering off; 180 } 181 182 - location = /oauth/client-metadata.json { 183 root /var/www/tranquil-pds; 184 default_type application/json; 185 sub_filter_once off;
··· 179 proxy_request_buffering off; 180 } 181 182 + location = /oauth-client-metadata.json { 183 root /var/www/tranquil-pds; 184 default_type application/json; 185 sub_filter_once off;
+3 -3
frontend/nginx-quadlet.conf
··· 10 gzip_vary on; 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 13 - location = /oauth/client-metadata.json { 14 default_type application/json; 15 sub_filter_once off; 16 sub_filter_types application/json; 17 - sub_filter '__PDS_HOSTNAME__' $host; 18 - try_files /oauth/client-metadata.json =404; 19 } 20 21 location /assets/ {
··· 10 gzip_vary on; 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 13 + location = /oauth-client-metadata.json { 14 default_type application/json; 15 sub_filter_once off; 16 sub_filter_types application/json; 17 + sub_filter '__FRONTEND_HOSTNAME__' $host; 18 + try_files /oauth-client-metadata.json =404; 19 } 20 21 location /assets/ {
+3 -3
frontend/nginx.conf
··· 10 gzip_vary on; 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 13 - location = /oauth/client-metadata.json { 14 default_type application/json; 15 sub_filter_once off; 16 sub_filter_types application/json; 17 - sub_filter '__PDS_HOSTNAME__' $host; 18 - try_files /oauth/client-metadata.json =404; 19 } 20 21 location /assets/ {
··· 10 gzip_vary on; 11 gzip_types text/plain text/css application/json application/javascript text/xml application/xml; 12 13 + location = /oauth-client-metadata.json { 14 default_type application/json; 15 sub_filter_once off; 16 sub_filter_types application/json; 17 + sub_filter '__FRONTEND_HOSTNAME__' $host; 18 + try_files /oauth-client-metadata.json =404; 19 } 20 21 location /assets/ {
+5 -5
frontend/public/oauth/client-metadata.json frontend/public/oauth-client-metadata.json
··· 1 { 2 - "client_id": "https://__PDS_HOSTNAME__/oauth/client-metadata.json", 3 - "client_name": "PDS Account Manager", 4 - "client_uri": "https://__PDS_HOSTNAME__", 5 "redirect_uris": [ 6 - "https://__PDS_HOSTNAME__/app/", 7 - "https://__PDS_HOSTNAME__/app/migrate" 8 ], 9 "grant_types": ["authorization_code", "refresh_token"], 10 "response_types": ["code"],
··· 1 { 2 + "client_id": "https://__FRONTEND_HOSTNAME__/oauth-client-metadata.json", 3 + "client_name": "Tranquil PDS Account Manager", 4 + "client_uri": "https://__FRONTEND_HOSTNAME__", 5 "redirect_uris": [ 6 + "https://__FRONTEND_HOSTNAME__/app/", 7 + "https://__FRONTEND_HOSTNAME__/app/migrate" 8 ], 9 "grant_types": ["authorization_code", "refresh_token"], 10 "response_types": ["code"],
+1 -1
frontend/src/lib/migration/atproto-client.ts
··· 1055 } 1056 1057 export function getMigrationOAuthClientId(): string { 1058 - return `${globalThis.location.origin}/oauth/client-metadata.json`; 1059 } 1060 1061 export function getMigrationOAuthRedirectUri(): string {
··· 1055 } 1056 1057 export function getMigrationOAuthClientId(): string { 1058 + return `${globalThis.location.origin}/oauth-client-metadata.json`; 1059 } 1060 1061 export function getMigrationOAuthRedirectUri(): string {
+1 -1
frontend/src/lib/oauth.ts
··· 14 ].join(" "); 15 16 const CLIENT_ID = !(import.meta.env.DEV) 17 - ? `${globalThis.location.origin}/oauth/client-metadata.json` 18 : `http://localhost/?scope=${SCOPES}`; 19 20 const REDIRECT_URI = `${globalThis.location.origin}/app/`;
··· 14 ].join(" "); 15 16 const CLIENT_ID = !(import.meta.env.DEV) 17 + ? `${globalThis.location.origin}/oauth-client-metadata.json` 18 : `http://localhost/?scope=${SCOPES}`; 19 20 const REDIRECT_URI = `${globalThis.location.origin}/app/`;
+1 -1
frontend/src/routes/ActAs.svelte
··· 51 method: 'POST', 52 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 53 body: new URLSearchParams({ 54 - client_id: `${hostname}/oauth/client-metadata.json`, 55 redirect_uri: `${hostname}/app/`, 56 response_type: 'code', 57 scope: 'atproto',
··· 51 method: 'POST', 52 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 53 body: new URLSearchParams({ 54 + client_id: `${hostname}/oauth-client-metadata.json`, 55 redirect_uri: `${hostname}/app/`, 56 response_type: 'code', 57 scope: 'atproto',
+3 -3
frontend/src/tests/migration/atproto-client.test.ts
··· 166 167 it("builds authorization URL with required parameters", () => { 168 const url = buildOAuthAuthorizationUrl(mockMetadata, { 169 - clientId: "https://example.com/oauth/client-metadata.json", 170 redirectUri: "https://example.com/migrate", 171 codeChallenge: "abc123", 172 state: "state123", ··· 177 expect(parsed.pathname).toBe("/oauth/authorize"); 178 expect(parsed.searchParams.get("response_type")).toBe("code"); 179 expect(parsed.searchParams.get("client_id")).toBe( 180 - "https://example.com/oauth/client-metadata.json", 181 ); 182 expect(parsed.searchParams.get("redirect_uri")).toBe( 183 "https://example.com/migrate", ··· 256 it("returns client metadata URL based on origin", () => { 257 const clientId = getMigrationOAuthClientId(); 258 expect(clientId).toBe( 259 - `${globalThis.location.origin}/oauth/client-metadata.json`, 260 ); 261 }); 262 });
··· 166 167 it("builds authorization URL with required parameters", () => { 168 const url = buildOAuthAuthorizationUrl(mockMetadata, { 169 + clientId: "https://example.com/oauth-client-metadata.json", 170 redirectUri: "https://example.com/migrate", 171 codeChallenge: "abc123", 172 state: "state123", ··· 177 expect(parsed.pathname).toBe("/oauth/authorize"); 178 expect(parsed.searchParams.get("response_type")).toBe("code"); 179 expect(parsed.searchParams.get("client_id")).toBe( 180 + "https://example.com/oauth-client-metadata.json", 181 ); 182 expect(parsed.searchParams.get("redirect_uri")).toBe( 183 "https://example.com/migrate", ··· 256 it("returns client metadata URL based on origin", () => { 257 const clientId = getMigrationOAuthClientId(); 258 expect(clientId).toBe( 259 + `${globalThis.location.origin}/oauth-client-metadata.json`, 260 ); 261 }); 262 });
+1 -1
module.nix
··· 261 } 262 263 (lib.optionalAttrs (cfg.frontend.package != null) { 264 - "= /oauth/client-metadata.json" = { 265 root = "${cfg.frontend.package}"; 266 extraConfig = '' 267 default_type application/json;
··· 261 } 262 263 (lib.optionalAttrs (cfg.frontend.package != null) { 264 + "= /oauth-client-metadata.json" = { 265 root = "${cfg.frontend.package}"; 266 extraConfig = '' 267 default_type application/json;
+2 -2
nginx.frontend.conf
··· 92 proxy_request_buffering off; 93 } 94 95 - location = /oauth/client-metadata.json { 96 proxy_pass http://frontend; 97 proxy_http_version 1.1; 98 proxy_set_header Host $host; 99 proxy_set_header Accept-Encoding ""; 100 sub_filter_once off; 101 sub_filter_types application/json; 102 - sub_filter '__PDS_HOSTNAME__' $host; 103 } 104 105 location /oauth/ {
··· 92 proxy_request_buffering off; 93 } 94 95 + location = /oauth-client-metadata.json { 96 proxy_pass http://frontend; 97 proxy_http_version 1.1; 98 proxy_set_header Host $host; 99 proxy_set_header Accept-Encoding ""; 100 sub_filter_once off; 101 sub_filter_types application/json; 102 + sub_filter '__FRONTEND_HOSTNAME__' $host; 103 } 104 105 location /oauth/ {
+3 -3
test.nix
··· 147 code = http_status("/xrpc/_health", host="alice.pds.test") 148 assert code == "200", f"subdomain routing failed: {code}" 149 150 - with subtest("client-metadata.json served with host substitution"): 151 - meta_raw = http_get("/oauth/client-metadata.json") 152 meta = json.loads(meta_raw) 153 - assert "client_id" in meta, f"no client_id in client-metadata: {meta}" 154 assert "pds.test" in meta_raw, "host substitution did not apply" 155 156 with subtest("static assets location exists"):
··· 147 code = http_status("/xrpc/_health", host="alice.pds.test") 148 assert code == "200", f"subdomain routing failed: {code}" 149 150 + with subtest("oauth-client-metadata.json served with host substitution"): 151 + meta_raw = http_get("/oauth-client-metadata.json") 152 meta = json.loads(meta_raw) 153 + assert "client_id" in meta, f"no client_id in oauth-client-metadata: {meta}" 154 assert "pds.test" in meta_raw, "host substitution did not apply" 155 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