use serde::Deserialize; /// Default polling interval for the handle-sync worker, in seconds. /// Bumped to 5 minutes — handle changes are infrequent and a missing /// `@handle` is purely cosmetic, so we don't need to hammer the PLC. fn default_handle_sync_interval() -> u64 { 300 } /// Default for `APPVIEW_AUTH_REQUIRED`. /// /// `true` — the AppView's private endpoints (notifications, home /// timeline) reject unauthenticated requests. Fail closed: an operator /// who forgets the variable gets the safe behaviour, and the only way /// to serve another user's notifications to an anonymous caller is to /// opt out explicitly. fn default_auth_required() -> bool { true } #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub pds_host: String, pub pds_port: u16, pub pds_public_url: String, pub pds_handle_dns_zone: String, pub pds_jwt_secret: String, pub appview_host: String, pub appview_port: u16, pub appview_public_url: String, pub jetstream_url: String, pub jetstream_collections: Vec, pub database_url_pds: String, pub database_url_appview: String, pub s3_endpoint: String, pub s3_region: String, pub s3_access_key: String, pub s3_secret_key: String, pub s3_bucket_pds: String, pub s3_bucket_appview: String, pub plc_directory_url: String, /// Cluster-internal URL the AppView uses to reach the PDS (e.g. /// `http://pds-server:3000`). Falls back to `pds_public_url` when /// unset. Splitting this from `pds_public_url` lets a single /// deployment point the AppView at the in-cluster PDS hostname /// (which may not be reachable from outside) while clients /// still see the public URL. #[serde(default)] pub pds_internal_url: Option, /// Optional shared secret for `POST /internal/ingest-commit`. If unset, /// the endpoint accepts anonymous requests (dev mode). If set, callers /// must send `X-Ingest-Secret: `. #[serde(default)] pub appview_ingest_secret: Option, /// How often the handle-sync worker scans the `posts` table for rows /// with an empty `handle` column and resolves them via the PLC /// directory. Default: 300s (5 minutes). #[serde(default = "default_handle_sync_interval")] pub appview_handle_sync_interval_secs: u64, /// Whether the AppView enforces bearer-token auth on the endpoints /// that serve a single user's private data (`/api/notifications*`, /// `/api/timeline/home`). Default `true`. /// /// Set `APPVIEW_AUTH_REQUIRED=false` to get the pre-auth behaviour /// (every endpoint public). That mode exists for two callers: /// the fail-open integration suites, which seed synthetic DIDs the /// PDS has never issued a token for, and an instance that is /// already isolated at the network layer (VPN / private subnet). /// The AppView warns loudly at startup when it is off. #[serde(default = "default_auth_required")] pub appview_auth_required: bool, /// Browser origins allowed to call the AppView's `/api/*` routes, /// from the comma-separated `APPVIEW_CORS_ORIGINS`. Empty means /// "no allowlist configured" — the AppView then keeps the historic /// `Access-Control-Allow-Origin: *` behaviour and warns at startup. /// /// Example (Tauri webview origins differ per platform): /// `APPVIEW_CORS_ORIGINS=tauri://localhost,http://127.0.0.1:1430` #[serde(default)] pub appview_cors_origins: Vec, } impl AppConfig { pub fn from_env() -> anyhow::Result { let env = |k: &str| std::env::var(k).map_err(|_| anyhow::anyhow!("missing env: {k}")); let collections: Vec = env("JETSTREAM_COLLECTIONS")? .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); Ok(Self { pds_host: env("PDS_HOST")?, pds_port: env("PDS_PORT")?.parse()?, pds_public_url: env("PDS_PUBLIC_URL")?, pds_handle_dns_zone: env("PDS_HANDLE_DNS_ZONE")?, pds_jwt_secret: env("PDS_JWT_SECRET")?, appview_host: env("APPVIEW_HOST")?, appview_port: env("APPVIEW_PORT")?.parse()?, appview_public_url: env("APPVIEW_PUBLIC_URL")?, jetstream_url: env("JETSTREAM_URL")?, jetstream_collections: collections, database_url_pds: env("DATABASE_URL_PDS")?, database_url_appview: env("DATABASE_URL_APPVIEW")?, s3_endpoint: env("S3_ENDPOINT")?, s3_region: env("S3_REGION")?, s3_access_key: env("S3_ACCESS_KEY")?, s3_secret_key: env("S3_SECRET_KEY")?, s3_bucket_pds: env("S3_BUCKET_PDS")?, s3_bucket_appview: env("S3_BUCKET_APPVIEW")?, plc_directory_url: env("PLC_DIRECTORY_URL")?, pds_internal_url: std::env::var("PDS_INTERNAL_URL").ok(), appview_ingest_secret: std::env::var("APPVIEW_INGEST_SECRET").ok(), appview_handle_sync_interval_secs: std::env::var("APPVIEW_HANDLE_SYNC_INTERVAL_SECS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or_else(default_handle_sync_interval), appview_auth_required: std::env::var("APPVIEW_AUTH_REQUIRED") .ok() .map(|s| parse_bool_env(&s)) .unwrap_or_else(default_auth_required), appview_cors_origins: std::env::var("APPVIEW_CORS_ORIGINS") .ok() .map(|s| parse_csv_env(&s)) .unwrap_or_default(), }) } /// The `did:web:` DID of *this* PDS, derived from `PDS_PUBLIC_URL`. /// /// One derivation, two consumers: `com.atproto.server.describeServer` /// (which used to return a hardcoded `did:web:pds.maarcadetweet.local` /// no matter what the operator configured) and /// `GET /.well-known/did.json`, which publishes the server's signing /// key under exactly this id. If those two ever disagreed, a client /// that trusts `describeServer` would fetch the key document of a /// different identity. pub fn pds_did(&self) -> String { did_web_from_url(&self.pds_public_url) } /// Base URL the AppView uses to reach the PDS. /// /// `PDS_INTERNAL_URL` when set (the cluster-internal hostname), /// otherwise `PDS_PUBLIC_URL`. Both the handle-sync resolver and the /// signing-key fetch go through here, so the two can't end up /// talking to different PDS instances. pub fn pds_base_url(&self) -> String { self.pds_internal_url .clone() .unwrap_or_else(|| self.pds_public_url.clone()) } } /// Interpret an environment variable as a boolean. /// /// Accepts the spellings people actually type in a `.env` file. Anything /// unrecognised counts as `false` for an explicitly-set variable — the /// caller decides what an *absent* variable means (see /// [`default_auth_required`]), and a typo like `APPVIEW_AUTH_REQUIRED=ture` /// must never silently read as "on" when the operator's intent was to /// switch something off... nor as "off" for a security switch. Since /// this is only reached when the variable *is* set, and the only /// security-relevant user of it defaults to `true` when unset, we treat /// unknown values as `false` and rely on the startup warning to make a /// disabled auth switch impossible to miss in the logs. fn parse_bool_env(raw: &str) -> bool { matches!( raw.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" ) } /// Split a comma-separated environment variable into trimmed, /// non-empty entries. `"a, b,,c "` → `["a", "b", "c"]`. fn parse_csv_env(raw: &str) -> Vec { raw.split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() } /// Turn an `http(s)://host[:port][/path]` URL into a `did:web:` DID. /// /// The did:web method spec maps the authority to the method-specific /// id, with two wrinkles that matter here: /// /// - a port is **percent-encoded** (`:` → `%3A`), because a bare colon /// already separates the DID's own segments. `http://127.0.0.1:2583` /// is therefore `did:web:127.0.0.1%3A2583`, *not* /// `did:web:127.0.0.1:2583` (which would parse as host `127.0.0.1` /// plus a path segment `2583`). /// - path segments, if any, are appended separated by `:`. /// /// The default ports (80/443) are kept rather than stripped: the /// resolution rule is a textual one, and a client that reverses this /// mapping has to end up at the same URL we serve the document from. pub fn did_web_from_url(url: &str) -> String { // Strip the scheme. We accept a bare `host:port` too, which is what // a misconfigured `PDS_PUBLIC_URL` often contains. let rest = url .trim() .trim_start_matches("https://") .trim_start_matches("http://") .trim_end_matches('/'); // Drop any userinfo (`user@host`) and query/fragment — neither has // a place in a did:web identifier. let rest = rest.split(['?', '#']).next().unwrap_or(rest); let rest = rest.rsplit('@').next().unwrap_or(rest); let mut parts = rest.split('/'); let authority = parts.next().unwrap_or(""); let host = authority.replacen(':', "%3A", 1); let mut did = format!("did:web:{host}"); for segment in parts.filter(|s| !s.is_empty()) { did.push(':'); did.push_str(segment); } did } #[cfg(test)] mod tests { use super::*; #[test] fn did_web_encodes_port_as_percent_3a() { // The dev default. A literal colon here would be read as a // did:web path segment, so it has to be percent-encoded. assert_eq!( did_web_from_url("http://127.0.0.1:2583"), "did:web:127.0.0.1%3A2583" ); assert_eq!( did_web_from_url("https://pds.example.com:8443"), "did:web:pds.example.com%3A8443" ); } #[test] fn did_web_without_port_is_plain_host() { assert_eq!( did_web_from_url("https://pds.maarcadetweet.local"), "did:web:pds.maarcadetweet.local" ); // Trailing slash must not produce an empty path segment. assert_eq!( did_web_from_url("https://pds.example.com/"), "did:web:pds.example.com" ); // Scheme-less input is tolerated. assert_eq!(did_web_from_url("pds.example.com"), "did:web:pds.example.com"); } #[test] fn did_web_appends_path_segments_with_colons() { assert_eq!( did_web_from_url("https://example.com/user/alice"), "did:web:example.com:user:alice" ); // Port + path together: only the port gets percent-encoded. assert_eq!( did_web_from_url("http://example.com:2583/pds"), "did:web:example.com%3A2583:pds" ); } #[test] fn did_web_ignores_userinfo_query_and_fragment() { assert_eq!( did_web_from_url("https://user@example.com?x=1#frag"), "did:web:example.com" ); } #[test] fn bool_env_accepts_common_spellings() { for on in ["1", "true", "TRUE", " yes ", "on"] { assert!(parse_bool_env(on), "{on} should parse as true"); } for off in ["0", "false", "no", "off", "", "nonsense"] { assert!(!parse_bool_env(off), "{off} should parse as false"); } } #[test] fn csv_env_trims_and_drops_empties() { assert_eq!( parse_csv_env("tauri://localhost, http://127.0.0.1:1430 ,,"), vec![ "tauri://localhost".to_string(), "http://127.0.0.1:1430".to_string() ] ); assert!(parse_csv_env(" ").is_empty()); } }