use std::path::Path;
use serde::Serialize;
use url::Url;
use crate::error::XurlError;
#[derive(Debug, Clone)]
pub struct Config {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub auth_url: String,
pub token_url: String,
pub api_base_url: String,
pub info_url: String,
pub app_name: String,
pub(crate) redirect_uri_source: ResolveSource,
pub(crate) redirect_uri_from_env: bool,
pub http_timeout_secs: u64,
}
pub const DEFAULT_REDIRECT_URI: &str = "http://localhost:8080/callback";
impl Config {
#[must_use]
pub fn new() -> Self {
let client_id = env_or_default("CLIENT_ID", "");
let client_secret = env_or_default("CLIENT_SECRET", "");
let redirect_uri_env = std::env::var("REDIRECT_URI").ok();
let redirect_uri_from_env = redirect_uri_env.is_some();
let redirect_uri = redirect_uri_env.unwrap_or_else(|| DEFAULT_REDIRECT_URI.to_string());
let redirect_uri_source = if redirect_uri_from_env {
ResolveSource::EnvVar
} else {
ResolveSource::BuiltInDefault
};
let auth_url = env_or_default("AUTH_URL", "https://x.com/i/oauth2/authorize");
let token_url = env_or_default("TOKEN_URL", "https://api.x.com/2/oauth2/token");
let api_base_url = env_or_default("API_BASE_URL", "https://api.x.com");
let info_url = env_or_default("INFO_URL", &format!("{api_base_url}/2/users/me"));
Self {
client_id,
client_secret,
redirect_uri,
auth_url,
token_url,
api_base_url,
info_url,
app_name: String::new(),
redirect_uri_source,
redirect_uri_from_env,
http_timeout_secs: crate::api::DEFAULT_TIMEOUT_SECS,
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl Config {
#[must_use]
pub fn default_store_path() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".xurl")
}
pub fn validate_redirect_uri(uri: &str) -> crate::error::Result<Url> {
let parsed = Url::parse(uri)
.map_err(|e| XurlError::validation(format!("invalid redirect URI: {e}")))?;
let scheme = parsed.scheme();
if scheme == "https" {
return Ok(parsed);
}
if scheme == "http"
&& let Some(host) = parsed.host_str()
&& matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]")
{
return Ok(parsed);
}
Err(XurlError::validation(format!(
"redirect URI must be https, or http on loopback (localhost / 127.0.0.1 / [::1]); got: {uri}"
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ResolveSource {
EnvVar,
AppConfig,
BuiltInDefault,
}
#[allow(dead_code)] impl ResolveSource {
pub(crate) fn as_text_label(&self) -> &'static str {
match self {
Self::EnvVar => "REDIRECT_URI environment variable",
Self::AppConfig => "app config",
Self::BuiltInDefault => "built-in default",
}
}
pub(crate) fn is_env_var(&self) -> bool {
matches!(self, Self::EnvVar)
}
}
#[allow(dead_code)] pub(crate) struct ResolvedRedirectUri {
pub uri: String,
pub source: ResolveSource,
}
pub(crate) fn resolve_redirect_uri_from(
env_value: Option<String>,
stored: Option<&str>,
) -> ResolvedRedirectUri {
if let Some(v) = env_value {
if Config::validate_redirect_uri(&v).is_ok() {
return ResolvedRedirectUri {
uri: v,
source: ResolveSource::EnvVar,
};
}
crate::output::warn_stderr(
"REDIRECT_URI env value rejected by validation; falling through to next precedence level",
);
}
if let Some(s) = stored
&& !s.is_empty()
{
return ResolvedRedirectUri {
uri: s.to_string(),
source: ResolveSource::AppConfig,
};
}
ResolvedRedirectUri {
uri: DEFAULT_REDIRECT_URI.to_string(),
source: ResolveSource::BuiltInDefault,
}
}
#[must_use]
#[allow(private_interfaces)] pub fn resolve_redirect_uri(store_path: &Path, app_name: &str) -> ResolvedRedirectUri {
let env = std::env::var("REDIRECT_URI").ok();
let store = crate::store::TokenStore::new_with_path(store_path.to_str().unwrap_or("."));
let stored = store.get_app_redirect_uri(app_name).map(str::to_string);
resolve_redirect_uri_from(env, stored.as_deref())
}
fn env_or_default(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_redirect_uri_from_env_wins_over_stored() {
let resolved = resolve_redirect_uri_from(
Some("https://example.com/cb".to_string()),
Some("http://stored.example.com/cb"),
);
assert_eq!(resolved.source, ResolveSource::EnvVar);
assert_eq!(resolved.uri, "https://example.com/cb");
}
#[test]
fn resolve_redirect_uri_from_stored_wins_over_default() {
let resolved = resolve_redirect_uri_from(None, Some("http://localhost:9090/cb"));
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
fn resolve_redirect_uri_from_default_fallback() {
let resolved = resolve_redirect_uri_from(None, None);
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
#[test]
fn resolve_redirect_uri_from_empty_stored_falls_through_to_default() {
let resolved = resolve_redirect_uri_from(None, Some(""));
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
#[test]
fn resolve_redirect_uri_from_invalid_env_falls_through_to_stored() {
let resolved = resolve_redirect_uri_from(
Some("not-a-url".to_string()),
Some("http://localhost:9090/cb"),
);
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
fn resolve_source_as_text_label_exhaustive() {
for variant in [
ResolveSource::EnvVar,
ResolveSource::AppConfig,
ResolveSource::BuiltInDefault,
] {
let label = variant.as_text_label();
match variant {
ResolveSource::EnvVar => {
assert_eq!(label, "REDIRECT_URI environment variable");
}
ResolveSource::AppConfig => {
assert_eq!(label, "app config");
}
ResolveSource::BuiltInDefault => {
assert_eq!(label, "built-in default");
}
}
}
}
#[test]
fn resolve_source_serialize_kebab_case_exhaustive() {
for variant in [
ResolveSource::EnvVar,
ResolveSource::AppConfig,
ResolveSource::BuiltInDefault,
] {
let json = serde_json::to_string(&variant).expect("serialize ResolveSource");
match variant {
ResolveSource::EnvVar => assert_eq!(json, "\"env-var\""),
ResolveSource::AppConfig => assert_eq!(json, "\"app-config\""),
ResolveSource::BuiltInDefault => assert_eq!(json, "\"built-in-default\""),
}
}
}
#[test]
fn resolve_source_is_env_var_predicate() {
assert!(ResolveSource::EnvVar.is_env_var());
assert!(!ResolveSource::AppConfig.is_env_var());
assert!(!ResolveSource::BuiltInDefault.is_env_var());
}
use serial_test::serial;
use std::fs;
use tempfile::TempDir;
fn write_store_with_redirect_uri(path: &std::path::Path, app: &str, uri: &str) {
let yaml = format!(
"apps:\n {app}:\n client_id: ''\n client_secret: ''\n redirect_uri: '{uri}'\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
);
fs::write(path, yaml).expect("write tempdir store");
}
fn write_empty_store(path: &std::path::Path, app: &str) {
let yaml = format!(
"apps:\n {app}:\n client_id: ''\n client_secret: ''\n oauth2_tokens: {{}}\ndefault_app: {app}\n"
);
fs::write(path, yaml).expect("write tempdir store");
}
#[test]
#[serial]
fn resolve_redirect_uri_env_wins() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_store_with_redirect_uri(&store_path, "app1", "http://localhost:7777/cb");
unsafe {
std::env::set_var("REDIRECT_URI", "https://example.com/cb");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
assert_eq!(resolved.source, ResolveSource::EnvVar);
assert_eq!(resolved.uri, "https://example.com/cb");
}
#[test]
#[serial]
fn resolve_redirect_uri_stored_when_no_env() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_store_with_redirect_uri(&store_path, "app1", "http://localhost:9090/cb");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
assert_eq!(resolved.source, ResolveSource::AppConfig);
assert_eq!(resolved.uri, "http://localhost:9090/cb");
}
#[test]
#[serial]
fn resolve_redirect_uri_default_fallback() {
let tmp = TempDir::new().expect("create tempdir for redirect_uri test");
let store_path = tmp.path().join(".xurl");
write_empty_store(&store_path, "app1");
unsafe {
std::env::remove_var("REDIRECT_URI");
}
let resolved = resolve_redirect_uri(&store_path, "app1");
assert_eq!(resolved.source, ResolveSource::BuiltInDefault);
assert_eq!(resolved.uri, DEFAULT_REDIRECT_URI);
}
}