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}