A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
207 lines
6.7 KiB
Rust
207 lines
6.7 KiB
Rust
//! 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<RusqliteService>,
|
|
server_id: &str,
|
|
) -> Result<Vec<Profile>, 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<RusqliteService>,
|
|
user_id: &str,
|
|
) -> Result<Option<Profile>, 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<RusqliteService>,
|
|
user_id: &str,
|
|
) -> Result<Option<(String, PinState)>, 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<String>)> = 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<RusqliteService>,
|
|
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<RusqliteService>, 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<RusqliteService>,
|
|
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<RusqliteService>, 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(())
|
|
}
|