use async_trait::async_trait;
use night_fury_core::BrowserSession;
use serde_json::Value;
use std::time::{Duration, SystemTime};
use crate::error::{AuthFailureKind, SiteError};
#[derive(Debug, Clone)]
pub enum SessionStatus {
Valid,
Degrading {
estimated_expiry: Option<SystemTime>,
hint: String,
},
Expired,
Blocked {
reason: String,
retry_after: Option<Duration>,
},
Unknown,
}
#[derive(Debug, Clone, Default)]
pub struct FailureIndicators {
pub status: Option<u16>,
pub body_preview: String,
pub final_url: Option<String>,
pub response_headers: Vec<(String, String)>,
}
#[async_trait]
pub trait Site: Send + Sync + 'static {
fn id(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn cookie_domain_patterns(&self) -> &'static [&'static str];
fn refresh_url(&self) -> &'static str;
fn refresh_interval_min(&self) -> Duration {
Duration::from_secs(60)
}
async fn refresh(&self, session: &BrowserSession) -> Result<Vec<Value>, SiteError> {
session
.refresh_cookies(self.refresh_url())
.await
.map_err(|e| SiteError::RefreshFailed {
site: self.id(),
reason: format!("navigate failed: {e}"),
})?;
let pattern = self
.cookie_domain_patterns()
.first()
.copied()
.unwrap_or("*");
session
.get_cookies_for_domain(pattern)
.await
.map_err(|e| SiteError::RefreshFailed {
site: self.id(),
reason: format!("get_cookies_for_domain failed: {e}"),
})
}
async fn validate(&self, session: &BrowserSession) -> Result<SessionStatus, SiteError>;
async fn attempt_login(
&self,
_session: &BrowserSession,
_credentials: &Credentials,
) -> Result<Vec<Value>, SiteError> {
Err(SiteError::ManualLoginRequired { site: self.id() })
}
fn detect_auth_failure(&self, _indicators: &FailureIndicators) -> Option<AuthFailureKind> {
None
}
}
#[derive(Clone)]
pub enum Credentials {
UsernamePassword {
username: String,
password: String, },
OAuth {
refresh_token: String,
client_id: String,
},
CookieJar(Vec<Value>),
Manual,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Credentials::UsernamePassword { username, .. } => f
.debug_struct("UsernamePassword")
.field("username", username)
.field("password", &"***")
.finish(),
Credentials::OAuth { client_id, .. } => f
.debug_struct("OAuth")
.field("refresh_token", &"***")
.field("client_id", client_id)
.finish(),
Credentials::CookieJar(v) => f.debug_tuple("CookieJar").field(&v.len()).finish(),
Credentials::Manual => f.debug_struct("Manual").finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_status_debug() {
let s = SessionStatus::Valid;
assert!(format!("{s:?}").contains("Valid"));
let s = SessionStatus::Blocked {
reason: "captcha".into(),
retry_after: Some(Duration::from_secs(30)),
};
let debug = format!("{s:?}");
assert!(debug.contains("Blocked"));
assert!(debug.contains("captcha"));
}
#[test]
fn failure_indicators_default_empty() {
let f = FailureIndicators::default();
assert!(f.status.is_none());
assert!(f.body_preview.is_empty());
assert!(f.final_url.is_none());
assert!(f.response_headers.is_empty());
}
#[test]
fn credentials_debug_hides_secrets() {
let c = Credentials::UsernamePassword {
username: "alice".into(),
password: "supersecret".into(),
};
let debug = format!("{c:?}");
assert!(debug.contains("alice"));
assert!(!debug.contains("supersecret"));
assert!(debug.contains("***"));
}
#[test]
fn credentials_oauth_debug_hides_token() {
let c = Credentials::OAuth {
refresh_token: "tok_xyz_123".into(),
client_id: "client_abc".into(),
};
let debug = format!("{c:?}");
assert!(debug.contains("client_abc"));
assert!(!debug.contains("tok_xyz_123"));
}
}