//! Database access for profiles. //! //! Everything here takes an explicit `user_id`. There is no ambient "current //! user" in this module — the caller has to say who it means, which is what //! stops a switch half-applying and writing one profile's state under another's //! id. //! //! TRACES: UR-082, UR-083 | DR-267, DR-268 use std::sync::Arc; use chrono::{DateTime, Utc}; use super::pin::PinState; use super::{Profile, UnlockMethod}; use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService}; /// List every profile known for a server, most recently used first. /// /// A profile's unlock method is derived from the presence of a `user_pins` row /// rather than stored twice, so the two can never disagree. /// /// TRACES: UR-082 | DR-267 pub async fn list_profiles( db: &Arc, server_id: &str, ) -> Result, String> { let query = Query::with_params( "SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at, CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin FROM users u LEFT JOIN user_pins p ON p.user_id = u.id WHERE u.server_id = ? ORDER BY u.last_login_at DESC", vec![QueryParam::String(server_id.to_string())], ); db.query_many(query, |row| { let has_pin: i32 = row.get(5)?; Ok(Profile { user_id: row.get(0)?, username: row.get(1)?, server_id: row.get(2)?, avatar_tag: None, unlock_method: if has_pin != 0 { UnlockMethod::Pin } else { UnlockMethod::None }, last_used_at: row.get(4)?, is_active: row.get::<_, i32>(3)? != 0, }) }) .await .map_err(|e| e.to_string()) } /// Fetch a single profile, or `None` if this device does not know it. /// /// TRACES: UR-082 | DR-267 pub async fn get_profile( db: &Arc, user_id: &str, ) -> Result, String> { let query = Query::with_params( "SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at, CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin FROM users u LEFT JOIN user_pins p ON p.user_id = u.id WHERE u.id = ?", vec![QueryParam::String(user_id.to_string())], ); db.query_optional(query, |row| { let has_pin: i32 = row.get(5)?; Ok(Profile { user_id: row.get(0)?, username: row.get(1)?, server_id: row.get(2)?, avatar_tag: None, unlock_method: if has_pin != 0 { UnlockMethod::Pin } else { UnlockMethod::None }, last_used_at: row.get(4)?, is_active: row.get::<_, i32>(3)? != 0, }) }) .await .map_err(|e| e.to_string()) } /// The stored PIN hash and attempt counters, or `None` when no PIN is set. /// /// TRACES: UR-083 | DR-268 pub async fn get_pin( db: &Arc, user_id: &str, ) -> Result, String> { let query = Query::with_params( "SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?", vec![QueryParam::String(user_id.to_string())], ); let row: Option<(String, i64, Option)> = db .query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) .await .map_err(|e| e.to_string())?; Ok(row.map(|(hash, failed, locked)| { let locked_until = locked .and_then(|s| DateTime::parse_from_rfc3339(&s).ok()) .map(|dt| dt.with_timezone(&Utc)); ( hash, PinState { failed_count: failed.max(0) as u32, locked_until, }, ) })) } /// Store (or replace) a profile's PIN, resetting its counters. /// /// TRACES: UR-083 | DR-268 pub async fn set_pin( db: &Arc, user_id: &str, pin_hash: &str, ) -> Result<(), String> { let query = Query::with_params( "INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at) VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET pin_hash = excluded.pin_hash, failed_count = 0, locked_until = NULL, updated_at = CURRENT_TIMESTAMP", vec![ QueryParam::String(user_id.to_string()), QueryParam::String(pin_hash.to_string()), ], ); db.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Remove a profile's PIN, making it a one-tap profile. /// /// TRACES: UR-083 | DR-268 pub async fn clear_pin(db: &Arc, user_id: &str) -> Result<(), String> { let query = Query::with_params( "DELETE FROM user_pins WHERE user_id = ?", vec![QueryParam::String(user_id.to_string())], ); db.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Persist the counter state produced by [`super::pin::evaluate`]. /// /// This is what makes a lockout survive a restart: the deadline is on disk, not /// in a process-lifetime counter that closing the app would clear. /// /// TRACES: UR-083 | DR-268 pub async fn save_pin_state( db: &Arc, user_id: &str, state: &PinState, ) -> Result<(), String> { let locked = match state.locked_until { Some(dt) => QueryParam::String(dt.to_rfc3339()), None => QueryParam::Null, }; let query = Query::with_params( "UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?", vec![ QueryParam::Int64(i64::from(state.failed_count)), locked, QueryParam::String(user_id.to_string()), ], ); db.execute(query).await.map_err(|e| e.to_string())?; Ok(()) } /// Forget a profile: its PIN, its per-user rows, and its `users` row. /// /// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile /// from this device is a local act; invalidating a token the person may be using /// on their phone is not what "remove from this TV" means. The caller deletes the /// stored token separately. /// /// TRACES: UR-082 | DR-267 pub async fn remove_profile(db: &Arc, user_id: &str) -> Result<(), String> { // ON DELETE CASCADE covers user_pins, user_data, user_item_visibility, // user_libraries, download_grants and the rest; the users row is the root. let query = Query::with_params( "DELETE FROM users WHERE id = ?", vec![QueryParam::String(user_id.to_string())], ); db.execute(query).await.map_err(|e| e.to_string())?; Ok(()) }