use std::env;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct Config {
pub bind: SocketAddr,
pub db_path: PathBuf,
pub public_url: String,
pub allowed_dids: Vec<String>,
pub poll_interval: Duration,
pub retention_days: u32,
pub proxy_images: bool,
pub beta_cap: i64,
pub trusted_ip_header: Option<String>,
pub max_subs_per_did: i64,
pub max_feeds_global: i64,
pub max_entries_per_feed: i64,
pub db_size_watermark_bytes: i64,
pub sidecar: SidecarConfig,
pub cookie_secret: String,
pub dev_did: Option<String>,
pub resolver_base: String,
}
#[derive(Debug, Clone)]
pub struct SidecarConfig {
pub public_url: String,
pub internal_url: String,
pub internal_secret: String,
}
const DEV_INTERNAL_SECRET: &str = "dev-internal-secret-change-me";
const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8081";
const DEV_COOKIE_SECRET: &str = "featherreader-dev-cookie-secret-change-me";
impl Default for SidecarConfig {
fn default() -> Self {
Self {
public_url: DEFAULT_SIDECAR_URL.to_string(),
internal_url: DEFAULT_SIDECAR_URL.to_string(),
internal_secret: DEV_INTERNAL_SECRET.to_string(),
}
}
}
impl SidecarConfig {
pub fn login_url(&self) -> String {
format!("{}/login", self.public_url)
}
pub fn session_url(&self, session_id: &str) -> String {
format!("{}/internal/session/{}", self.internal_url, session_id)
}
pub fn repo_url(&self) -> String {
format!("{}/internal/repo", self.internal_url)
}
}
impl Default for Config {
fn default() -> Self {
Self {
bind: SocketAddr::from(([127, 0, 0, 1], 8080)),
db_path: PathBuf::from("featherreader.db"),
public_url: "http://localhost:8080".to_string(),
allowed_dids: Vec::new(),
poll_interval: Duration::from_secs(3600),
retention_days: 90,
proxy_images: false,
beta_cap: 100,
trusted_ip_header: None,
max_subs_per_did: 500,
max_feeds_global: 10_000,
max_entries_per_feed: 2_000,
db_size_watermark_bytes: 2 * 1024 * 1024 * 1024,
sidecar: SidecarConfig::default(),
cookie_secret: DEV_COOKIE_SECRET.to_string(),
dev_did: None,
resolver_base: crate::atproto::DEFAULT_RESOLVER_HOST.to_string(),
}
}
}
impl Config {
pub fn from_env() -> Result<Self> {
let defaults = Config::default();
let bind = match env_opt("FEATHERREADER_BIND").or_else(|| env_opt("FEATHERREADER_ADDR")) {
Some(raw) => raw
.parse::<SocketAddr>()
.with_context(|| format!("FEATHERREADER_BIND: invalid socket address {raw:?}"))?,
None => defaults.bind,
};
let db_path = env_opt("FEATHERREADER_DB")
.map(PathBuf::from)
.unwrap_or(defaults.db_path);
let public_url = env_opt("FEATHERREADER_PUBLIC_URL")
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or(defaults.public_url);
let allowed_dids = env_opt("FEATHERREADER_ALLOWED_DIDS")
.map(|raw| {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or(defaults.allowed_dids);
let poll_interval = match env_opt("FEATHERREADER_POLL_INTERVAL") {
Some(raw) => {
let secs: u64 = raw.parse().with_context(|| {
format!("FEATHERREADER_POLL_INTERVAL: expected seconds, got {raw:?}")
})?;
Duration::from_secs(secs)
}
None => defaults.poll_interval,
};
let retention_days = match env_opt("FEATHERREADER_RETENTION_DAYS") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_RETENTION_DAYS: expected an integer, got {raw:?}")
})?,
None => defaults.retention_days,
};
let proxy_images = match env_opt("FEATHERREADER_PROXY_IMAGES") {
Some(raw) => parse_bool(&raw).with_context(|| {
format!("FEATHERREADER_PROXY_IMAGES: expected a boolean, got {raw:?}")
})?,
None => defaults.proxy_images,
};
let beta_cap = match env_opt("FEATHERREADER_BETA_CAP") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_BETA_CAP: expected an integer, got {raw:?}")
})?,
None => defaults.beta_cap,
};
let trusted_ip_header =
env_opt("FEATHERREADER_TRUSTED_IP_HEADER").map(|h| h.trim().to_ascii_lowercase());
let max_subs_per_did = match env_opt("FEATHERREADER_MAX_SUBS_PER_DID") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_MAX_SUBS_PER_DID: expected an integer, got {raw:?}")
})?,
None => defaults.max_subs_per_did,
};
let max_feeds_global = match env_opt("FEATHERREADER_MAX_FEEDS") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_MAX_FEEDS: expected an integer, got {raw:?}")
})?,
None => defaults.max_feeds_global,
};
let max_entries_per_feed = match env_opt("FEATHERREADER_MAX_ENTRIES_PER_FEED") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_MAX_ENTRIES_PER_FEED: expected an integer, got {raw:?}")
})?,
None => defaults.max_entries_per_feed,
};
let db_size_watermark_bytes = match env_opt("FEATHERREADER_DB_SIZE_WATERMARK_BYTES") {
Some(raw) => raw.parse().with_context(|| {
format!("FEATHERREADER_DB_SIZE_WATERMARK_BYTES: expected an integer, got {raw:?}")
})?,
None => defaults.db_size_watermark_bytes,
};
let sidecar_url = env_opt("SIDECAR_PUBLIC_URL")
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or_else(|| defaults.sidecar.public_url.clone());
let internal_url = env_opt("SIDECAR_INTERNAL_URL")
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or_else(|| sidecar_url.clone());
let internal_secret = env_opt("SIDECAR_INTERNAL_SECRET")
.unwrap_or_else(|| defaults.sidecar.internal_secret.clone());
let sidecar = SidecarConfig {
public_url: sidecar_url,
internal_url,
internal_secret,
};
let cookie_secret = env_opt("FEATHERREADER_COOKIE_SECRET")
.unwrap_or_else(|| defaults.cookie_secret.clone());
let dev_did = env_opt("FEATHERREADER_DEV_DID");
let resolver_base = env_opt("FEATHERREADER_RESOLVER_HOST")
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or(defaults.resolver_base);
let config = Self {
bind,
db_path,
public_url,
allowed_dids,
poll_interval,
retention_days,
proxy_images,
beta_cap,
trusted_ip_header,
max_subs_per_did,
max_feeds_global,
max_entries_per_feed,
db_size_watermark_bytes,
sidecar,
cookie_secret,
dev_did,
resolver_base,
};
config.validate_secrets()?;
Ok(config)
}
fn is_prod_like(&self) -> bool {
if env_opt("FEATHERREADER_ENV")
.map(|v| v.eq_ignore_ascii_case("prod") || v.eq_ignore_ascii_case("production"))
.unwrap_or(false)
{
return true;
}
!self.bind.ip().is_loopback() || public_url_is_non_loopback(&self.public_url)
}
fn validate_secrets(&self) -> Result<()> {
if !self.is_prod_like() {
return Ok(());
}
check_secret(
"FEATHERREADER_COOKIE_SECRET",
&self.cookie_secret,
DEV_COOKIE_SECRET,
)?;
check_secret(
"SIDECAR_INTERNAL_SECRET",
&self.sidecar.internal_secret,
DEV_INTERNAL_SECRET,
)?;
if self.sidecar.internal_url == self.sidecar.public_url
&& public_url_is_non_loopback(&self.sidecar.public_url)
{
anyhow::bail!(
"SIDECAR_INTERNAL_URL is unset (defaulting to the public \
SIDECAR_PUBLIC_URL '{}') on a production-like instance: the internal \
API secret and all session/repo traffic would traverse the public \
network. Set SIDECAR_INTERNAL_URL to the sidecar's loopback/private \
address (e.g. http://127.0.0.1:8081).",
self.sidecar.public_url
);
}
Ok(())
}
pub fn did_allowed(&self, did: &str) -> bool {
self.allowed_dids.is_empty() || self.allowed_dids.iter().any(|d| d == did)
}
pub fn admin_seed_dids(&self) -> &[String] {
&self.allowed_dids
}
}
const MIN_SECRET_BYTES: usize = 32;
fn check_secret(var: &str, value: &str, dev_constant: &str) -> Result<()> {
if value.is_empty() || value == dev_constant {
anyhow::bail!(
"{var} is unset or still the published dev default on a non-loopback (production) \
instance; refusing to boot. Set {var} to a random secret of at least \
{MIN_SECRET_BYTES} bytes."
);
}
if value.len() < MIN_SECRET_BYTES {
anyhow::bail!(
"{var} is too short ({} bytes) for a production instance; it must be at least \
{MIN_SECRET_BYTES} bytes.",
value.len()
);
}
Ok(())
}
fn public_url_is_non_loopback(public_url: &str) -> bool {
match url::Url::parse(public_url) {
Ok(u) => match u.host() {
Some(url::Host::Domain(d)) => {
!(d.eq_ignore_ascii_case("localhost") || d.eq_ignore_ascii_case("localhost."))
}
Some(url::Host::Ipv4(ip)) => !ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => !ip.is_loopback(),
None => true,
},
Err(_) => true,
}
}
fn env_opt(key: &str) -> Option<String> {
match env::var(key) {
Ok(v) if !v.trim().is_empty() => Some(v),
_ => None,
}
}
fn parse_bool(raw: &str) -> Result<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => anyhow::bail!("not a boolean: {other:?}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
let c = Config::default();
assert_eq!(c.bind.port(), 8080);
assert_eq!(c.poll_interval, Duration::from_secs(3600));
assert_eq!(c.retention_days, 90);
assert!(!c.proxy_images);
assert!(c.allowed_dids.is_empty());
assert_eq!(c.beta_cap, 100);
assert!(c.trusted_ip_header.is_none());
assert_eq!(c.max_subs_per_did, 500);
assert_eq!(c.max_feeds_global, 10_000);
assert_eq!(c.max_entries_per_feed, 2_000);
assert_eq!(c.db_size_watermark_bytes, 2 * 1024 * 1024 * 1024);
}
#[test]
fn admin_seed_reuses_allowed_dids() {
let open = Config::default();
assert!(open.admin_seed_dids().is_empty());
let gated = Config {
allowed_dids: vec!["did:plc:me".to_string(), "did:plc:you".to_string()],
..Config::default()
};
assert_eq!(gated.admin_seed_dids(), &["did:plc:me", "did:plc:you"]);
}
#[test]
fn open_instance_allows_any_did() {
let c = Config::default();
assert!(c.did_allowed("did:plc:anything"));
}
#[test]
fn allow_list_gates_dids() {
let c = Config {
allowed_dids: vec!["did:plc:me".to_string()],
..Config::default()
};
assert!(c.did_allowed("did:plc:me"));
assert!(!c.did_allowed("did:plc:stranger"));
}
#[test]
fn parse_bool_accepts_common_spellings() {
assert!(parse_bool("Yes").unwrap());
assert!(!parse_bool("OFF").unwrap());
assert!(parse_bool("maybe").is_err());
}
#[test]
fn loopback_instance_keeps_dev_fallback_secrets() {
let c = Config::default();
assert!(!c.is_prod_like());
assert!(c.validate_secrets().is_ok());
}
#[test]
fn public_bind_with_dev_cookie_secret_refuses_boot() {
let c = Config {
bind: SocketAddr::from(([0, 0, 0, 0], 8080)),
..Config::default()
};
assert!(c.is_prod_like());
let err = c.validate_secrets().unwrap_err().to_string();
assert!(err.contains("FEATHERREADER_COOKIE_SECRET"), "{err}");
}
#[test]
fn public_bind_with_short_secret_refuses_boot() {
let c = Config {
bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
cookie_secret: "too-short".to_string(),
..Config::default()
};
assert!(c.is_prod_like());
assert!(c.validate_secrets().is_err());
}
#[test]
fn public_bind_with_dev_sidecar_secret_refuses_boot() {
let c = Config {
bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
cookie_secret: "x".repeat(48),
..Config::default()
};
let err = c.validate_secrets().unwrap_err().to_string();
assert!(err.contains("SIDECAR_INTERNAL_SECRET"), "{err}");
}
#[test]
fn public_bind_with_strong_secrets_boots() {
let c = Config {
bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
cookie_secret: "a".repeat(48),
sidecar: SidecarConfig {
public_url: DEFAULT_SIDECAR_URL.to_string(),
internal_url: DEFAULT_SIDECAR_URL.to_string(),
internal_secret: "b".repeat(48),
},
..Config::default()
};
assert!(c.is_prod_like());
assert!(c.validate_secrets().is_ok());
}
#[test]
fn public_sidecar_url_without_internal_url_refuses_boot() {
let c = Config {
bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
cookie_secret: "a".repeat(48),
sidecar: SidecarConfig {
public_url: "https://feather-reader.com/oauth".to_string(),
internal_url: "https://feather-reader.com/oauth".to_string(),
internal_secret: "b".repeat(48),
},
..Config::default()
};
let err = c.validate_secrets().unwrap_err().to_string();
assert!(err.contains("SIDECAR_INTERNAL_URL"), "{err}");
}
#[test]
fn public_sidecar_url_with_loopback_internal_url_boots() {
let c = Config {
bind: SocketAddr::from(([203, 0, 113, 5], 8080)),
cookie_secret: "a".repeat(48),
sidecar: SidecarConfig {
public_url: "https://feather-reader.com/oauth".to_string(),
internal_url: "http://127.0.0.1:8081".to_string(),
internal_secret: "b".repeat(48),
},
..Config::default()
};
assert!(c.validate_secrets().is_ok());
}
#[test]
fn public_url_non_loopback_detection() {
assert!(!public_url_is_non_loopback("http://localhost:8080"));
assert!(!public_url_is_non_loopback("http://127.0.0.1:8080"));
assert!(!public_url_is_non_loopback("http://[::1]:8080"));
assert!(public_url_is_non_loopback("https://feather-reader.com"));
assert!(public_url_is_non_loopback("http://203.0.113.5"));
}
}