//! Who is asking: the session cookie resolved to an account, as an axum //! extractor every page handler takes. use super::{csrf_token, mac_equal, token_hash, SESSION_COOKIE}; use crate::db::{self, accounts::Account}; use crate::Shared; use axum::extract::FromRequestParts; use axum::http::request::Parts; use axum::http::{header, HeaderMap}; use std::convert::Infallible; #[derive(Clone, Debug)] pub struct ViewerAccount { pub account: Account, /// The cookie value, which CSRF tokens are bound to. pub session: String, /// The session's record (oxygenhub#223): "this browser" in the list. pub session_id: i64, pub csrf: String, /// Unread notifications, for the header. pub unread: i64, } /// The signed-in account, if any. Never fails: anonymous is a valid viewer. #[derive(Clone, Debug, Default)] pub struct Viewer(pub Option); impl Viewer { pub fn account(&self) -> Option<&Account> { self.0.as_ref().map(|v| &v.account) } pub fn signed_in(&self) -> bool { self.0.is_some() } pub fn is_admin(&self) -> bool { self.account().is_some_and(|a| a.is_admin) } pub fn csrf(&self) -> &str { self.0.as_ref().map(|v| v.csrf.as_str()).unwrap_or("") } /// Whether a submitted CSRF token belongs to this session. pub fn csrf_ok(&self, token: &str) -> bool { self.0.as_ref().is_some_and(|v| mac_equal(&v.csrf, token)) } } /// The value of a named cookie in the request. pub fn cookie(headers: &HeaderMap, name: &str) -> Option { headers.get_all(header::COOKIE).iter().filter_map(|v| v.to_str().ok()).flat_map(|line| line.split(';')).find_map(|kv| { let (k, v) = kv.trim().split_once('=')?; (k == name).then(|| v.trim().to_string()) }) } /// Resolve a session cookie value to its account: a live session record /// (oxygenhub#223) whose salt is still the account's. pub async fn resolve(app: &Shared, value: &str) -> Option<(ViewerAccount, db::sessions::Session)> { if value.is_empty() || value.len() > 128 { return None; } let session = db::sessions::by_hash(&app.db, &token_hash(value)).await.ok()??; let account = db::accounts::account_by_id(&app.db, session.account_id).await.ok()??; if account.blocked() || !session.live(&account.session_salt, db::now_ms()) { return None; } let csrf = csrf_token(&app.cookie_key, value); let unread = db::notify::unread(&app.db, account.id).await.unwrap_or(0); let viewer = ViewerAccount { account, session: value.to_string(), session_id: session.id, csrf, unread }; Some((viewer, session)) } impl FromRequestParts for Viewer { type Rejection = Infallible; async fn from_request_parts(parts: &mut Parts, app: &Shared) -> Result { let Some(value) = cookie(&parts.headers, SESSION_COOKIE) else { return Ok(Viewer(None)) }; let Some((viewer, session)) = resolve(app, &value).await else { return Ok(Viewer(None)) }; let address = crate::api::client_addr(app, &parts.headers, &parts.extensions); let agent: String = parts .headers .get(header::USER_AGENT) .and_then(|v| v.to_str().ok()) .unwrap_or("") .chars() .filter(|c| !c.is_control()) .take(300) .collect(); let pool = app.db.clone(); let id = viewer.account.id; tokio::spawn(async move { let _ = db::accounts::touch_seen(&pool, id).await; let _ = db::sessions::touch(&pool, &session, &address, &agent, db::now_ms()).await; }); Ok(Viewer(Some(viewer))) } }