Skip to main content

#
jellytau_lib/profiles/
store.rs

    1//! Database access for profiles.
    2//!
    3//! Everything here takes an explicit `user_id`. There is no ambient "current
    4//! user" in this module — the caller has to say who it means, which is what
    5//! stops a switch half-applying and writing one profile's state under another's
    6//! id.
    7//!
    8//! TRACES: UR-082, UR-083 | DR-267, DR-268
    9
    10use std::sync::Arc;
    11
    12use chrono::{DateTime, Utc};
    13
    14use super::pin::PinState;
    15use super::{Profile, UnlockMethod};
    16use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
    17
    18/// List every profile known for a server, most recently used first.
    19///
    20/// A profile's unlock method is derived from the presence of a `user_pins` row
    21/// rather than stored twice, so the two can never disagree.
    22///
    23/// TRACES: UR-082 | DR-267
    24pub async fn list_profiles(
    25    db: &Arc<RusqliteService>,
    26    server_id: &str,
    27) -> Result<Vec<Profile>, String> {
    28    let query = Query::with_params(
    29        "SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
    30                CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
    31         FROM users u
    32         LEFT JOIN user_pins p ON p.user_id = u.id
    33         WHERE u.server_id = ?
    34         ORDER BY u.last_login_at DESC",
    35        vec![QueryParam::String(server_id.to_string())],
    36    );
    37
    38    db.query_many(query, |row| {
    39        let has_pin: i32 = row.get(5)?;
    40        Ok(Profile {
    41            user_id: row.get(0)?,
    42            username: row.get(1)?,
    43            server_id: row.get(2)?,
    44            avatar_tag: None,
    45            unlock_method: if has_pin != 0 {
    46                UnlockMethod::Pin
    47            } else {
    48                UnlockMethod::None
    49            },
    50            last_used_at: row.get(4)?,
    51            is_active: row.get::<_, i32>(3)? != 0,
    52        })
    53    })
    54    .await
    55    .map_err(|e| e.to_string())
    56}
    57
    58/// Fetch a single profile, or `None` if this device does not know it.
    59///
    60/// TRACES: UR-082 | DR-267
    61pub async fn get_profile(
    62    db: &Arc<RusqliteService>,
    63    user_id: &str,
    64) -> Result<Option<Profile>, String> {
    65    let query = Query::with_params(
    66        "SELECT u.id, u.username, u.server_id, u.is_active, u.last_login_at,
    67                CASE WHEN p.user_id IS NULL THEN 0 ELSE 1 END AS has_pin
    68         FROM users u
    69         LEFT JOIN user_pins p ON p.user_id = u.id
    70         WHERE u.id = ?",
    71        vec![QueryParam::String(user_id.to_string())],
    72    );
    73
    74    db.query_optional(query, |row| {
    75        let has_pin: i32 = row.get(5)?;
    76        Ok(Profile {
    77            user_id: row.get(0)?,
    78            username: row.get(1)?,
    79            server_id: row.get(2)?,
    80            avatar_tag: None,
    81            unlock_method: if has_pin != 0 {
    82                UnlockMethod::Pin
    83            } else {
    84                UnlockMethod::None
    85            },
    86            last_used_at: row.get(4)?,
    87            is_active: row.get::<_, i32>(3)? != 0,
    88        })
    89    })
    90    .await
    91    .map_err(|e| e.to_string())
    92}
    93
    94/// The stored PIN hash and attempt counters, or `None` when no PIN is set.
    95///
    96/// TRACES: UR-083 | DR-268
    97pub async fn get_pin(
    98    db: &Arc<RusqliteService>,
    99    user_id: &str,
    100) -> Result<Option<(String, PinState)>, String> {
    101    let query = Query::with_params(
    102        "SELECT pin_hash, failed_count, locked_until FROM user_pins WHERE user_id = ?",
    103        vec![QueryParam::String(user_id.to_string())],
    104    );
    105
    106    let row: Option<(String, i64, Option<String>)> = db
    107        .query_optional(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
    108        .await
    109        .map_err(|e| e.to_string())?;
    110
    111    Ok(row.map(|(hash, failed, locked)| {
    112        let locked_until = locked
    113            .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
    114            .map(|dt| dt.with_timezone(&Utc));
    115        (
    116            hash,
    117            PinState {
    118                failed_count: failed.max(0) as u32,
    119                locked_until,
    120            },
    121        )
    122    }))
    123}
    124
    125/// Store (or replace) a profile's PIN, resetting its counters.
    126///
    127/// TRACES: UR-083 | DR-268
    128pub async fn set_pin(
    129    db: &Arc<RusqliteService>,
    130    user_id: &str,
    131    pin_hash: &str,
    132) -> Result<(), String> {
    133    let query = Query::with_params(
    134        "INSERT INTO user_pins (user_id, pin_hash, failed_count, locked_until, updated_at)
    135         VALUES (?, ?, 0, NULL, CURRENT_TIMESTAMP)
    136         ON CONFLICT(user_id) DO UPDATE SET
    137            pin_hash = excluded.pin_hash,
    138            failed_count = 0,
    139            locked_until = NULL,
    140            updated_at = CURRENT_TIMESTAMP",
    141        vec![
    142            QueryParam::String(user_id.to_string()),
    143            QueryParam::String(pin_hash.to_string()),
    144        ],
    145    );
    146    db.execute(query).await.map_err(|e| e.to_string())?;
    147    Ok(())
    148}
    149
    150/// Remove a profile's PIN, making it a one-tap profile.
    151///
    152/// TRACES: UR-083 | DR-268
    153pub async fn clear_pin(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
    154    let query = Query::with_params(
    155        "DELETE FROM user_pins WHERE user_id = ?",
    156        vec![QueryParam::String(user_id.to_string())],
    157    );
    158    db.execute(query).await.map_err(|e| e.to_string())?;
    159    Ok(())
    160}
    161
    162/// Persist the counter state produced by [`super::pin::evaluate`].
    163///
    164/// This is what makes a lockout survive a restart: the deadline is on disk, not
    165/// in a process-lifetime counter that closing the app would clear.
    166///
    167/// TRACES: UR-083 | DR-268
    168pub async fn save_pin_state(
    169    db: &Arc<RusqliteService>,
    170    user_id: &str,
    171    state: &PinState,
    172) -> Result<(), String> {
    173    let locked = match state.locked_until {
    174        Some(dt) => QueryParam::String(dt.to_rfc3339()),
    175        None => QueryParam::Null,
    176    };
    177    let query = Query::with_params(
    178        "UPDATE user_pins SET failed_count = ?, locked_until = ? WHERE user_id = ?",
    179        vec![
    180            QueryParam::Int64(i64::from(state.failed_count)),
    181            locked,
    182            QueryParam::String(user_id.to_string()),
    183        ],
    184    );
    185    db.execute(query).await.map_err(|e| e.to_string())?;
    186    Ok(())
    187}
    188
    189/// Forget a profile: its PIN, its per-user rows, and its `users` row.
    190///
    191/// Deliberately does **not** call Jellyfin's logout endpoint. Removing a profile
    192/// from this device is a local act; invalidating a token the person may be using
    193/// on their phone is not what "remove from this TV" means. The caller deletes the
    194/// stored token separately.
    195///
    196/// TRACES: UR-082 | DR-267
    197pub async fn remove_profile(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
    198    // ON DELETE CASCADE covers user_pins, user_data, user_item_visibility,
    199    // user_libraries, download_grants and the rest; the users row is the root.
    200    let query = Query::with_params(
    201        "DELETE FROM users WHERE id = ?",
    202        vec![QueryParam::String(user_id.to_string())],
    203    );
    204    db.execute(query).await.map_err(|e| e.to_string())?;
    205    Ok(())
    206}