Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
appview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396//! Command-line configuration and on-disk paths.
use std::net::SocketAddr;use std::path::PathBuf;use std::sync::LazyLock;
use clap::{Parser, Subcommand};
use crate::atproto::lexicon::{ActorProfile, ModListing, ModStar, ModVersion, Record};
/// https://atproto.com/guides/permission-requests////// Changing this invalidates every session.pub static OAUTH_SCOPE: LazyLock<String> = LazyLock::new(|| { format!( "atproto repo:{} repo:{} repo:{} repo:{} blob:*/*", ModListing::NSID, ModVersion::NSID, ModStar::NSID, ActorProfile::NSID )});
/// Whether a URL points at `localhost` or a loopback IP.////// Doesn't recognize bracketed IPv6 (`[::1]`) -- same limitation as/// `oauth::session`'s host parsing.pub fn host_is_loopback(url: &str) -> bool { let host = url .trim_start_matches("https://") .trim_start_matches("http://") .split(['/', ':']) .next() .unwrap_or(""); host == "localhost" || host .parse::<std::net::IpAddr>() .map(|ip| ip.is_loopback()) .unwrap_or(false)}
/// Credentials for the devshell's object store, matching what it starts with./// Only ever used against a loopback endpoint - see [`Config::s3_credentials`].const DEV_S3_ACCESS_KEY: &str = "starhaven";const DEV_S3_SECRET_KEY: &str = "starhaven-dev-secret";
/// The starhaven.dev server.#[derive(Parser)]#[command(name = "server", version)]pub struct Config { /// What to do instead of serving. #[command(subcommand)] pub command: Option<Command>,
/// External base URL (with scheme). Defaults to the listen address. #[arg(long)] external_base: Option<String>,
/// Address:port to listen on. #[arg(long, default_value = "127.0.0.1:3000")] pub listen: SocketAddr,
/// State directory. #[arg(long, default_value = ".")] pub data_dir: PathBuf,
/// `tap` service hostname, for indexing repo/identity events into the /// local cache independent of who wrote them. #[arg(long, default_value = "localhost:2480")] pub tap_hostname: String,
/// Base URL of the dev-only PDS that `.test` handles resolve against. #[arg(long, default_value = "http://localhost:2583", hide = true)] pub dev_pds_url: String,
/// Base URL of the dev-only PLC directory that `.test`-handle DIDs /// resolve against. #[arg(long, default_value = "http://localhost:2582", hide = true)] pub dev_plc_url: String,
/// Endpoint of the S3-compatible object store holding mod deliverables. /// /// The default is the devshell's, addressed by IP rather than as /// `localhost`, which resolves to `::1` first and would miss it. #[arg(long, default_value = "http://127.0.0.1:9000")] pub s3_endpoint: String,
/// Bucket within the object store. Created on startup if missing. #[arg(long, default_value = "starhaven-dev")] pub s3_bucket: String,
/// Region to sign object store requests for. #[arg(long, default_value = "us-east-1")] pub s3_region: String,
/// Address the bucket as `bucket.endpoint/key` rather than /// `endpoint/bucket/key`. Most hosted providers want this; an object /// store on a bare IP cannot use it at all, since a bucket name cannot be /// prefixed onto one. #[arg(long)] pub s3_virtual_host: bool,
/// How long an upload can sit in the deliverables bucket without being /// published before the sweep deletes it. #[arg(long, default_value_t = 60 * 60)] pub deliverable_upload_grace_secs: u64,
/// How often the sweep for unpublished uploads runs. #[arg(long, default_value_t = 10 * 60)] pub deliverable_sweep_interval_secs: u64,
/// How many bytes of deliverables one author may store, across every mod /// they have. #[arg(long, default_value_t = 10 * 1024 * 1024 * 1024)] pub s3_user_quota_bytes: u64,
/// Path to a JSON file holding `accessKey` and `secretKey` for the object /// store. Required unless the endpoint is loopback. /// /// A path rather than the credentials themselves: the NixOS module puts /// this whole `Config` into a systemd unit's `ExecStart`, and that unit /// is world-readable in the Nix store. #[arg(long)] pub s3_credentials_file: Option<PathBuf>,
/// Path to a JSON file holding `host`, `port`, `username` and `password` /// for the SMTP server email is sent through. Required unless /// `--external-base` is loopback, where emails are printed instead. #[arg(long)] pub smtp_credentials_file: Option<PathBuf>,
/// Who email comes from. #[arg(long, default_value = "Star Haven <noreply@starhaven.dev>")] pub mail_from: String,
/// Base URL of the PDS that sign-up creates accounts on. Defaults to /// `--dev-pds-url`. #[arg(long)] signup_pds_url: Option<String>,
/// Base URL this server calls the sign-up PDS at, when it can reach it /// more directly than through `--signup-pds-url`, such as on loopback. /// The PDS must take `X-Forwarded-For` from this connection, to rate-limit /// each visitor rather than this server. Defaults to `--signup-pds-url`. #[arg(long)] signup_pds_internal_url: Option<String>,
/// Domain that sign-up's handles are under: picking "alice" makes /// `alice.<domain>`. #[arg(long, default_value = "test")] pub signup_handle_domain: String,
/// The account whose mods can credit people by name rather than by /// account, for mods it lists on their makers' behalf. With none, every /// credit is an account. #[arg(long)] pub staff_did: Option<String>,
/// Path to a JSON file holding the `identifier` and `password` of an /// admin account on the sign-up PDS, which mints the invite code each new /// account needs. Required unless that PDS is loopback, which is taken to /// be the devshell's. #[arg(long)] pub pds_admin_credentials_file: Option<PathBuf>,
/// Path to a JSON file holding the `siteKey` and `secretKey` of the /// Cloudflare Turnstile widget sign-up shows. Required unless /// `--external-base` is loopback, where Turnstile's test keys, which /// always pass, stand in. #[arg(long)] pub turnstile_credentials_file: Option<PathBuf>,
/// Where Turnstile responses are checked. #[arg( long, default_value = "https://challenges.cloudflare.com/turnstile/v0/siteverify", hide = true )] pub turnstile_verify_url: String,}
/// What the server can do besides serve.#[derive(Subcommand)]pub enum Command { /// Feature these mods on the homepage, in this order, in place of any /// featured before. Give each as its page's address, such as /// https://starhaven.dev/alice.test/master-quest. With none, features none. Feature { addresses: Vec<String> },}
impl Config { /// Parse arguments, then fill in defaults that depend on other arguments. pub fn parse() -> Self { <Self as Parser>::parse().resolve() }
/// Derive `external_base` from `listen` when it wasn't given. Every /// constructor must run this; the accessor assumes it has. pub(crate) fn resolve(mut self) -> Self { if self.external_base.is_none() { self.external_base = Some(format!("http://{}", self.listen)); } if self.signup_pds_url.is_none() { self.signup_pds_url = Some(self.dev_pds_url.clone()); } if self.signup_pds_internal_url.is_none() { self.signup_pds_internal_url = self.signup_pds_url.clone(); } self }
/// The object store's access key and secret. /// /// Falls back to the devshell's credentials when no file was given, but /// only against a loopback endpoint - otherwise a missing /// `--s3-credentials-file` in production would silently try to talk to a /// real provider using credentials published in this repo. pub fn s3_credentials(&self) -> anyhow::Result<(String, String)> { match &self.s3_credentials_file { Some(path) => crate::s3::read_credentials_file(path), None if host_is_loopback(&self.s3_endpoint) => { Ok((DEV_S3_ACCESS_KEY.to_string(), DEV_S3_SECRET_KEY.to_string())) } None => anyhow::bail!( "--s3-credentials-file is required for a non-loopback --s3-endpoint ({})", self.s3_endpoint ), } }
/// Base URL of the PDS that sign-up creates accounts on. pub fn signup_pds_url(&self) -> &str { self.signup_pds_url .as_deref() .expect("filled in by resolve") }
/// Base URL this server calls the sign-up PDS at. pub fn signup_pds_internal_url(&self) -> &str { self.signup_pds_internal_url .as_deref() .expect("filled in by resolve") }
/// External base URL. pub fn external_base(&self) -> &str { self.external_base.as_deref().expect("filled in by resolve") }
/// Path to the SQLite database file. Created on startup if missing. pub fn database_path(&self) -> PathBuf { self.data_dir.join("database.db") }
/// The OAuth client id (the `client-metadata.json` URL). pub fn oauth_client_id(&self) -> String { format!("{}/client-metadata.json", self.external_base()) }
/// Whether `external_base` is `localhost`/a loopback IP. pub fn is_loopback(&self) -> bool { host_is_loopback(self.external_base()) }
/// The `client_id` for login/callback: the `client-metadata.json` URL, /// or atproto OAuth's loopback form for local dev (which forces the /// token-exchange workaround in `oauth::public_client`). pub fn oauth_client_id_for_login(&self) -> String { if self.is_loopback() { format!( "http://localhost?redirect_uri={}&scope={}", urlencoding::encode(&self.oauth_redirect_uri()), urlencoding::encode(OAUTH_SCOPE.as_str()), ) } else { self.oauth_client_id() } }
/// The OAuth redirect URI. RFC 8252 forbids a `localhost` hostname here /// (only a literal loopback IP), even though `external_base` accepts /// `localhost` fine everywhere else - we bind `127.0.0.1` regardless, so /// substituting it in doesn't change what actually gets served. pub fn oauth_redirect_uri(&self) -> String { let base = self.external_base().replacen("localhost", "127.0.0.1", 1); format!("{base}/callback") }
/// The JWKS URI. pub fn jwks_uri(&self) -> String { format!("{}/jwks.json", self.external_base()) }}
#[cfg(test)]mod tests { use super::*;
fn config_with_base(external_base: &str) -> Config { Config::parse_from(["server", "--external-base", external_base]).resolve() }
#[test] fn external_base_defaults_to_listen_address() { let config = Config::parse_from(["server", "--listen", "127.0.0.1:9999"]).resolve(); assert_eq!(config.external_base(), "http://127.0.0.1:9999"); }
#[test] fn recognizes_loopback_hosts() { assert!(config_with_base("http://127.0.0.1:3000").is_loopback()); assert!(config_with_base("http://localhost:3000").is_loopback()); assert!(!config_with_base("https://starhaven.dev").is_loopback()); // Known limitation, not a supported case: bracketed IPv6 literals // aren't recognized (see the doc comment on `is_loopback`). assert!(!config_with_base("http://[::1]:3000").is_loopback()); }
#[test] fn loopback_uses_spec_localhost_client_id() { let config = config_with_base("http://127.0.0.1:3000"); let client_id = config.oauth_client_id_for_login(); assert!(client_id.starts_with("http://localhost?")); assert!(client_id.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback")); assert!(client_id.contains(&format!( "scope={}", urlencoding::encode(OAUTH_SCOPE.as_str()) ))); }
#[test] fn redirect_uri_never_uses_localhost_hostname() { // RFC 8252: the OAuth authorization server rejects a "localhost" // redirect_uri outright, even though "localhost" is a fine // `--external-base` for a human to type. let config = config_with_base("http://localhost:3000"); assert_eq!( config.oauth_redirect_uri(), "http://127.0.0.1:3000/callback" ); }
#[test] fn the_signup_pds_is_called_at_its_public_url_unless_told_otherwise() { let public_only = Config::parse_from(["server", "--signup-pds-url", "https://pds.starhaven.dev"]) .resolve(); assert_eq!( public_only.signup_pds_internal_url(), "https://pds.starhaven.dev" );
let internal = Config::parse_from([ "server", "--signup-pds-url", "https://pds.starhaven.dev", "--signup-pds-internal-url", "http://127.0.0.1:3000", ]) .resolve(); assert_eq!(internal.signup_pds_url(), "https://pds.starhaven.dev"); assert_eq!(internal.signup_pds_internal_url(), "http://127.0.0.1:3000"); }
#[test] fn dev_s3_credentials_are_refused_against_a_real_provider() { let local = Config::parse_from(["server"]).resolve(); assert!(local.s3_credentials().is_ok());
let remote = Config::parse_from([ "server", "--s3-endpoint", "https://fsn1.your-objectstorage.com", ]) .resolve(); assert!(remote.s3_credentials().is_err()); }
#[test] fn oauth_scope_covers_every_collection_the_appview_writes() { assert!(OAUTH_SCOPE.contains(ModListing::NSID)); assert!(OAUTH_SCOPE.contains(ModVersion::NSID)); assert!(OAUTH_SCOPE.contains(ModStar::NSID)); assert!(OAUTH_SCOPE.contains(ActorProfile::NSID)); }
#[test] fn non_loopback_uses_client_metadata_url() { let config = config_with_base("https://starhaven.dev"); assert_eq!( config.oauth_client_id_for_login(), "https://starhaven.dev/client-metadata.json" ); }}