feat(profiles): multi-user profiles with PIN switching
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).
This commit is contained in:
Generated
+48
@@ -176,6 +176,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ascii"
|
||||
version = "1.1.0"
|
||||
@@ -360,6 +372,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -390,6 +408,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -913,6 +940,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2212,7 @@ name = "jellytau"
|
||||
version = "0.11.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2200,6 +2229,7 @@ dependencies = [
|
||||
"libmpv-sys",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"password-hash",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
@@ -3065,6 +3095,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -4284,6 +4325,12 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -5593,6 +5640,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
rand = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||
tokio-util = "0.7"
|
||||
@@ -64,6 +64,12 @@ aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
getrandom = "0.2"
|
||||
|
||||
# Profile PIN hashing (DR-268). A switching gate against a member of the
|
||||
# household, not at-rest protection -- but a hash is the right primitive for a
|
||||
# gate, and Argon2id costs nothing extra over a weaker one.
|
||||
argon2 = "0.5"
|
||||
password-hash = { version = "0.5", features = ["alloc", "rand_core"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
|
||||
@@ -30,6 +30,39 @@ pub async fn auth_initialize(
|
||||
// Try to restore session from storage
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// A PIN-protected profile is not restored automatically. Restoring it would
|
||||
// hand the app a working token before anybody entered the code, leaving the
|
||||
// picker as decoration over a session that was already live — the gate has
|
||||
// to be on the session itself, not on which screen is shown. The frontend
|
||||
// sees `None`, asks `profiles_startup_target`, and lands on the picker.
|
||||
//
|
||||
// TRACES: UR-083 | DR-268, DR-274
|
||||
{
|
||||
let db_service = {
|
||||
let db = database.0.lock().map_err(|e| e.to_string())?;
|
||||
std::sync::Arc::new(db.service())
|
||||
};
|
||||
let locked: Option<String> = crate::storage::db_service::DatabaseService::query_optional(
|
||||
&*db_service,
|
||||
crate::storage::db_service::Query::new(
|
||||
"SELECT u.id FROM users u
|
||||
JOIN user_pins p ON p.user_id = u.id
|
||||
WHERE u.is_active = 1",
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if let Some(user_id) = locked {
|
||||
log::info!(
|
||||
"[AuthManager] Active profile {} is PIN-protected; not restoring its session",
|
||||
user_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod profiles;
|
||||
pub mod repository;
|
||||
pub mod sessions;
|
||||
pub mod storage;
|
||||
@@ -35,6 +36,7 @@ pub use playback_mode::*;
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use profiles::*;
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Profile commands: who can use this device, and how they get in.
|
||||
//!
|
||||
//! The rule that shapes this whole module: **switching is not logging out**.
|
||||
//! [`auth_logout`](super::auth::auth_logout) calls Jellyfin's logout endpoint,
|
||||
//! which invalidates the token server-side — so a switch built on it would make
|
||||
//! every switch back cost a password, which is the problem this feature exists
|
||||
//! to solve. Nothing here calls it.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268, DR-269, DR-270, DR-274
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::sessions::SessionPollerWrapper;
|
||||
use crate::commands::storage::{CredentialStoreWrapper, DatabaseWrapper};
|
||||
use crate::profiles::pin::{self, PinDecision, PinState};
|
||||
use crate::profiles::switch::{plan, SwitchStep};
|
||||
use crate::profiles::{startup_target, store, Profile, StartupTarget, UnlockOutcome};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// Settings key for "ask who's watching on start".
|
||||
const ASK_ON_START_KEY: &str = "profiles_ask_on_start";
|
||||
|
||||
fn service(db: &State<'_, DatabaseWrapper>) -> Result<Arc<RusqliteService>, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Ok(Arc::new(database.service()))
|
||||
}
|
||||
|
||||
/// The server this device is signed in to, as `(server_id, server_url)`.
|
||||
///
|
||||
/// Every profile operation is scoped to it — this is where the same-server
|
||||
/// constraint is actually enforced, rather than by omitting a URL field from a
|
||||
/// form.
|
||||
///
|
||||
/// The fallback to the `servers` table is not a convenience. When the last-used
|
||||
/// profile has a PIN, `auth_initialize` deliberately does **not** restore its
|
||||
/// session, so at startup there is no in-memory session to read — and the picker
|
||||
/// still has to know which server's profiles to list. Reading it from storage is
|
||||
/// what lets the PIN gate a real thing rather than just a screen.
|
||||
async fn current_server(
|
||||
db: &Arc<RusqliteService>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(String, String), String> {
|
||||
if let Some(session) = auth_manager.0.get_session().await {
|
||||
return Ok((session.server_id, session.server_url));
|
||||
}
|
||||
|
||||
db.query_optional(
|
||||
Query::new("SELECT id, url FROM servers ORDER BY last_connected_at DESC LIMIT 1"),
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "No server connected".to_string())
|
||||
}
|
||||
|
||||
async fn ask_on_start(db: &Arc<RusqliteService>) -> bool {
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(ASK_ON_START_KEY.to_string())],
|
||||
);
|
||||
db.query_optional(query, |row| row.get::<_, String>(0))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// List the accounts this device knows for the current server.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_list(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<Vec<Profile>, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
store::list_profiles(&svc, &server_id).await
|
||||
}
|
||||
|
||||
/// Whether startup should resume an account or ask who is watching.
|
||||
///
|
||||
/// The decision is backend state, so the frontend asks rather than computes it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_startup_target(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<StartupTarget, String> {
|
||||
let svc = service(&db)?;
|
||||
let (server_id, _) = match current_server(&svc, &auth_manager).await {
|
||||
Ok(pair) => pair,
|
||||
// Nothing signed in yet: the picker doubles as first-run login.
|
||||
Err(_) => return Ok(StartupTarget::Picker),
|
||||
};
|
||||
let profiles = store::list_profiles(&svc, &server_id).await?;
|
||||
Ok(startup_target(&profiles, ask_on_start(&svc).await))
|
||||
}
|
||||
|
||||
/// Read the "ask who's watching on start" setting.
|
||||
///
|
||||
/// Separate from [`profiles_startup_target`] on purpose: the target can be
|
||||
/// `Picker` for reasons that have nothing to do with this setting — a
|
||||
/// PIN-protected last profile always asks — so deriving the toggle's position
|
||||
/// from it would show the user a switch that does not describe what it controls.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_get_ask_on_start(db: State<'_, DatabaseWrapper>) -> Result<bool, String> {
|
||||
let svc = service(&db)?;
|
||||
Ok(ask_on_start(&svc).await)
|
||||
}
|
||||
|
||||
/// Turn "ask who's watching on start" on or off.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_ask_on_start(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(ASK_ON_START_KEY.to_string()),
|
||||
QueryParam::String(if enabled { "1" } else { "0" }.to_string()),
|
||||
],
|
||||
);
|
||||
svc.execute(query).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter a profile, with its PIN if it has one.
|
||||
///
|
||||
/// A profile with no PIN ignores whatever `pin` was passed — the frontend cannot
|
||||
/// invent a lock the backend does not have, and cannot skip one it does.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-267, DR-268, DR-270
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
pin_code: Option<String>,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
// Same-server constraint, checked at the point of use rather than trusted
|
||||
// from the caller.
|
||||
let (server_id, _) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
if let Some((hash, state)) = store::get_pin(&svc, &user_id).await? {
|
||||
let candidate = pin_code.unwrap_or_default();
|
||||
let matches = pin::verify_pin(&candidate, &hash);
|
||||
let (decision, next_state) = pin::evaluate(&state, chrono::Utc::now(), matches);
|
||||
store::save_pin_state(&svc, &user_id, &next_state).await?;
|
||||
|
||||
match decision {
|
||||
PinDecision::Reject { attempts_remaining } => {
|
||||
return Ok(UnlockOutcome::WrongPin { attempts_remaining })
|
||||
}
|
||||
PinDecision::Locked { until } => {
|
||||
return Ok(UnlockOutcome::LockedOut {
|
||||
until: until.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
PinDecision::Accept => {}
|
||||
}
|
||||
}
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Enter a profile with its Jellyfin password, for someone who has forgotten
|
||||
/// their PIN.
|
||||
///
|
||||
/// There is deliberately no reset token and no recovery secret: the account's
|
||||
/// own password is already the authority over it, and a second credential
|
||||
/// guarding the same thing would only be a weaker one. A successful password
|
||||
/// entry also clears the lockout, which is what makes a forgotten PIN a
|
||||
/// detour rather than a dead end.
|
||||
///
|
||||
/// TRACES: UR-084 | DR-269
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn profiles_unlock_with_password(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: State<'_, SessionPollerWrapper>,
|
||||
user_id: String,
|
||||
password: String,
|
||||
device_id: String,
|
||||
) -> Result<UnlockOutcome, String> {
|
||||
let svc = service(&db)?;
|
||||
let profile = store::get_profile(&svc, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("Unknown profile: {}", user_id))?;
|
||||
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
if profile.server_id != server_id {
|
||||
return Err("Profile belongs to a different server".to_string());
|
||||
}
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &profile.username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
if result.user.id != user_id {
|
||||
return Err("Signed in as a different account".to_string());
|
||||
}
|
||||
|
||||
save_token(&creds, &user_id, &result.access_token)?;
|
||||
|
||||
// The password got in, so the PIN counters have served their purpose.
|
||||
store::save_pin_state(&svc, &user_id, &PinState::fresh()).await?;
|
||||
|
||||
let outgoing = active_user_id(&svc).await;
|
||||
execute_switch(
|
||||
&app,
|
||||
&svc,
|
||||
&repository_manager,
|
||||
&session_poller,
|
||||
outgoing.as_deref(),
|
||||
&user_id,
|
||||
)
|
||||
.await?;
|
||||
adopt_session(db, creds, &auth_manager).await?;
|
||||
|
||||
Ok(UnlockOutcome::Ok { user_id })
|
||||
}
|
||||
|
||||
/// Add another account from the **current** server to this device.
|
||||
///
|
||||
/// Takes no server URL. That is the same-server constraint expressed as a
|
||||
/// signature rather than as form validation: there is no way to ask this command
|
||||
/// for an account somewhere else.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_add(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: State<'_, super::auth::AuthManagerWrapper>,
|
||||
username: String,
|
||||
password: String,
|
||||
pin_code: Option<String>,
|
||||
device_id: String,
|
||||
) -> Result<Profile, String> {
|
||||
if let Some(code) = &pin_code {
|
||||
pin::validate_pin(code)?;
|
||||
}
|
||||
|
||||
let svc = service(&db)?;
|
||||
let (server_id, server_url) = current_server(&svc, &auth_manager).await?;
|
||||
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
let insert = Query::with_params(
|
||||
"INSERT INTO users (id, server_id, username, last_login_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
username = excluded.username,
|
||||
last_login_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(result.user.id.clone()),
|
||||
QueryParam::String(server_id.clone()),
|
||||
QueryParam::String(result.user.name.clone()),
|
||||
],
|
||||
);
|
||||
svc.execute(insert).await.map_err(|e| e.to_string())?;
|
||||
|
||||
save_token(&creds, &result.user.id, &result.access_token)?;
|
||||
|
||||
if let Some(code) = pin_code {
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &result.user.id, &hash).await?;
|
||||
}
|
||||
|
||||
info!("[Profiles] Added profile {}", result.user.name);
|
||||
|
||||
store::get_profile(&svc, &result.user.id)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile vanished after being added".to_string())
|
||||
}
|
||||
|
||||
/// Set, change, or clear a profile's PIN.
|
||||
///
|
||||
/// Changing an existing PIN requires the current one. Clearing it (`new_pin =
|
||||
/// None`) does too — otherwise the lock could be removed by whoever is standing
|
||||
/// in front of the unlocked device, which is exactly who it exists to stop.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_set_pin(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
current_pin: Option<String>,
|
||||
new_pin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if let Some((hash, _)) = store::get_pin(&svc, &user_id).await? {
|
||||
let provided = current_pin.unwrap_or_default();
|
||||
if !pin::verify_pin(&provided, &hash) {
|
||||
return Err("Current PIN is incorrect".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match new_pin {
|
||||
Some(code) => {
|
||||
pin::validate_pin(&code)?;
|
||||
let hash = pin::hash_pin(&code)?;
|
||||
store::set_pin(&svc, &user_id, &hash).await
|
||||
}
|
||||
None => store::clear_pin(&svc, &user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a profile on this device.
|
||||
///
|
||||
/// Does not call Jellyfin's logout endpoint: removing an account from the family
|
||||
/// TV should not sign that person out on their phone. The stored token is
|
||||
/// deleted locally, which is the part that actually belongs to this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn profiles_remove(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
user_id: String,
|
||||
) -> Result<(), String> {
|
||||
let svc = service(&db)?;
|
||||
|
||||
if active_user_id(&svc).await.as_deref() == Some(user_id.as_str()) {
|
||||
return Err("Switch to another profile before removing this one".to_string());
|
||||
}
|
||||
|
||||
{
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
if let Err(e) = store.delete_token(&user_id) {
|
||||
warn!("[Profiles] Could not delete stored token: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
store::remove_profile(&svc, &user_id).await
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------------
|
||||
|
||||
fn save_token(
|
||||
creds: &State<'_, CredentialStoreWrapper>,
|
||||
user_id: &str,
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let store = creds.0.lock().map_err(|e| e.to_string())?;
|
||||
store
|
||||
.save_token(user_id, token)
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
async fn active_user_id(db: &Arc<RusqliteService>) -> Option<String> {
|
||||
db.query_optional(
|
||||
Query::new("SELECT id FROM users WHERE is_active = 1 LIMIT 1"),
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run a switch plan.
|
||||
///
|
||||
/// The ordering comes from [`crate::profiles::switch::plan`] rather than being
|
||||
/// written out here, because the ordering is the invariant worth testing and an
|
||||
/// end-to-end switch needs two real accounts on a real server to exercise.
|
||||
///
|
||||
/// One step is deliberately not executed here: `BuildRepository`. Repository
|
||||
/// handles are created by the frontend (`repository_create`) because building
|
||||
/// one needs the token and URL it already assembles at login, so the
|
||||
/// `profile-switched` event is the signal to do it. What stays in Rust is the
|
||||
/// part that matters — that the old handle is destroyed *before* the active user
|
||||
/// flips, so nothing can write under the wrong id in between.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn execute_switch(
|
||||
app: &tauri::AppHandle,
|
||||
db: &Arc<RusqliteService>,
|
||||
repository_manager: &State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
session_poller: &State<'_, SessionPollerWrapper>,
|
||||
from: Option<&str>,
|
||||
to: &str,
|
||||
) -> Result<(), String> {
|
||||
let online = true;
|
||||
let steps = plan(from, to, online);
|
||||
|
||||
for step in steps {
|
||||
match step {
|
||||
SwitchStep::StopPlayback => {
|
||||
// The queue cannot outlive its owner: a report landing after the
|
||||
// flip would attribute one account's viewing to another.
|
||||
if let Err(e) = app.emit("profile-switch-stop-playback", ()) {
|
||||
warn!("[Profiles] Could not signal playback stop: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::ParkSyncQueue { user_id } => {
|
||||
// Rows stay queued under their own user id; nothing is dropped.
|
||||
// Parking is simply declining to drain them under a different
|
||||
// token, which the drain already keys on.
|
||||
info!("[Profiles] Parking sync queue for {}", user_id);
|
||||
}
|
||||
SwitchStep::StopSessionPoller => session_poller.0.stop(),
|
||||
SwitchStep::ClearLockscreenMetadata => {
|
||||
if let Err(e) = app.emit("profile-switch-clear-metadata", ()) {
|
||||
warn!("[Profiles] Could not clear lockscreen metadata: {}", e);
|
||||
}
|
||||
}
|
||||
SwitchStep::DestroyRepository => {
|
||||
let manager = &repository_manager.0;
|
||||
for handle in manager.handles() {
|
||||
manager.destroy(&handle);
|
||||
}
|
||||
}
|
||||
SwitchStep::SetActiveUser { user_id } => {
|
||||
set_active_user(db, &user_id).await?;
|
||||
}
|
||||
SwitchStep::BuildRepository { .. } => {
|
||||
// Owned by the frontend; see the doc comment above.
|
||||
}
|
||||
SwitchStep::StartSessionPoller => {
|
||||
// The poller restarts with the new session once the frontend has
|
||||
// built its repository, for the same reason.
|
||||
}
|
||||
SwitchStep::RefreshVisibility { user_id } => {
|
||||
info!("[Profiles] Visibility refresh queued for {}", user_id);
|
||||
}
|
||||
SwitchStep::EmitSwitched { user_id } => {
|
||||
app.emit("profile-switched", serde_json::json!({ "userId": user_id }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make the newly-active profile the session the rest of the backend acts as.
|
||||
///
|
||||
/// The token lives in the credential store, which the frontend cannot read, so
|
||||
/// the swap has to happen here — the frontend then rebuilds its repository
|
||||
/// handle from the session it can now read back. This mirrors what
|
||||
/// [`auth_initialize`](super::auth::auth_initialize) does on a cold start, and
|
||||
/// deliberately reuses the same storage path rather than a second one that could
|
||||
/// drift from it.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
async fn adopt_session(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
creds: State<'_, CredentialStoreWrapper>,
|
||||
auth_manager: &State<'_, super::auth::AuthManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let active = super::storage::storage_get_active_session(db, creds)
|
||||
.await?
|
||||
.ok_or_else(|| "Profile has no stored session".to_string())?;
|
||||
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active.server_url)?;
|
||||
|
||||
auth_manager
|
||||
.0
|
||||
.set_session(Some(crate::auth::Session {
|
||||
user_id: active.user_id,
|
||||
username: active.username,
|
||||
server_id: active.server_id,
|
||||
server_url: normalized_url,
|
||||
server_name: active.server_name,
|
||||
access_token: active.access_token,
|
||||
verified: false,
|
||||
needs_reauth: false,
|
||||
}))
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_active_user(db: &Arc<RusqliteService>, user_id: &str) -> Result<(), String> {
|
||||
db.execute(Query::new("UPDATE users SET is_active = 0"))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.execute(Query::with_params(
|
||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![QueryParam::String(user_id.to_string())],
|
||||
))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod media_server;
|
||||
mod playback_mode;
|
||||
mod playback_reporting;
|
||||
mod player;
|
||||
mod profiles;
|
||||
mod repository;
|
||||
mod session_poller;
|
||||
pub mod settings;
|
||||
@@ -194,6 +195,15 @@ use commands::{
|
||||
playlist_move_item,
|
||||
playlist_remove_items,
|
||||
playlist_rename,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
// Remote session control commands
|
||||
remote_play_on_session,
|
||||
remote_send_command,
|
||||
@@ -1036,6 +1046,15 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
playlist_rename,
|
||||
playlist_get_items,
|
||||
playlist_add_items,
|
||||
profiles_add,
|
||||
profiles_get_ask_on_start,
|
||||
profiles_list,
|
||||
profiles_remove,
|
||||
profiles_set_ask_on_start,
|
||||
profiles_set_pin,
|
||||
profiles_startup_target,
|
||||
profiles_unlock,
|
||||
profiles_unlock_with_password,
|
||||
playlist_remove_items,
|
||||
playlist_move_item,
|
||||
// Diagnostics commands
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Multi-user profiles on one device.
|
||||
//!
|
||||
//! A "profile" is an account on the *currently connected* Jellyfin server that
|
||||
//! this device has signed into at least once. The rows have existed since the
|
||||
//! first schema (`users`), and so have `storage_get_users` /
|
||||
//! `storage_set_active_user`; what was missing was never the storage but the
|
||||
//! decision of who may switch to what — which is domain logic, and stays here.
|
||||
//!
|
||||
//! Two things this module is careful about:
|
||||
//!
|
||||
//! - **Switching is not logging out.** `auth_logout` calls Jellyfin's logout
|
||||
//! endpoint, which invalidates the token server-side. That is precisely the
|
||||
//! behaviour a switch must not have, or every switch back would need a
|
||||
//! password. Nothing here calls it.
|
||||
//! - **"Child account" is not modelled.** A child's profile is simply one with
|
||||
//! no PIN. The frontend receives an opaque [`UnlockMethod`] and renders it; it
|
||||
//! never infers a role, and no role taxonomy is invented on either side.
|
||||
//!
|
||||
//! TRACES: UR-082, UR-083, UR-084 | DR-267, DR-268
|
||||
|
||||
pub mod pin;
|
||||
pub mod store;
|
||||
pub mod switch;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a profile is entered.
|
||||
///
|
||||
/// Deliberately not "adult"/"child": the app has no way to know a person's age
|
||||
/// and no business encoding one. It knows whether a code is set.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-276
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UnlockMethod {
|
||||
/// One tap. No code set.
|
||||
None,
|
||||
/// A numeric code gates the switch.
|
||||
Pin,
|
||||
}
|
||||
|
||||
/// A switchable account on this device.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-267
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub server_id: String,
|
||||
/// Jellyfin's primary-image tag, for the tile. `None` renders initials.
|
||||
pub avatar_tag: Option<String>,
|
||||
pub unlock_method: UnlockMethod,
|
||||
pub last_used_at: Option<String>,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// The result of an unlock attempt.
|
||||
///
|
||||
/// Note the explicit field renames. tauri-specta emits tagged-union *fields*
|
||||
/// with their Rust names rather than camelCasing them, so a field that would
|
||||
/// differ between the two conventions is renamed here by hand — the same trap
|
||||
/// that produced `new_url` on the frontend once already.
|
||||
///
|
||||
/// TRACES: UR-083, UR-084 | DR-268, DR-269
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum UnlockOutcome {
|
||||
/// Switched. The profile is now active.
|
||||
Ok {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Wrong code, attempts left.
|
||||
WrongPin {
|
||||
#[serde(rename = "attemptsRemaining")]
|
||||
attempts_remaining: u32,
|
||||
},
|
||||
/// Too many wrong codes; refused until this RFC3339 instant.
|
||||
LockedOut { until: String },
|
||||
/// No code is recoverable from here — sign in with the account password.
|
||||
NeedsPassword,
|
||||
}
|
||||
|
||||
/// What the app should do when it starts.
|
||||
///
|
||||
/// The decision is backend state (profile count, PIN presence, a stored
|
||||
/// setting), so the frontend asks rather than computes. A single account with no
|
||||
/// PIN always resumes, which is what keeps this feature invisible until it is
|
||||
/// wanted.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StartupTarget {
|
||||
/// Resume this profile without asking.
|
||||
Resume {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
},
|
||||
/// Show the picker.
|
||||
Picker,
|
||||
}
|
||||
|
||||
/// Decide the startup target from the profiles present and the user's setting.
|
||||
///
|
||||
/// Pure, because the rule is worth testing and the inputs are trivial to state:
|
||||
///
|
||||
/// - No profiles at all → picker (which renders as the first-run login).
|
||||
/// - The last-used profile has a PIN → picker, regardless of the setting. A code
|
||||
/// that could be skipped by relaunching is not a code.
|
||||
/// - More than one profile and "ask who's watching" is on → picker.
|
||||
/// - Otherwise → resume, exactly as the app behaved before profiles existed.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-274
|
||||
pub fn startup_target(profiles: &[Profile], ask_on_start: bool) -> StartupTarget {
|
||||
let last_used = profiles
|
||||
.iter()
|
||||
.max_by(|a, b| a.last_used_at.cmp(&b.last_used_at));
|
||||
|
||||
match last_used {
|
||||
None => StartupTarget::Picker,
|
||||
Some(p) if p.unlock_method == UnlockMethod::Pin => StartupTarget::Picker,
|
||||
Some(_) if ask_on_start && profiles.len() > 1 => StartupTarget::Picker,
|
||||
Some(p) => StartupTarget::Resume {
|
||||
user_id: p.user_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile(id: &str, unlock: UnlockMethod, last_used: Option<&str>) -> Profile {
|
||||
Profile {
|
||||
user_id: id.to_string(),
|
||||
username: id.to_string(),
|
||||
server_id: "server-1".to_string(),
|
||||
avatar_tag: None,
|
||||
unlock_method: unlock,
|
||||
last_used_at: last_used.map(|s| s.to_string()),
|
||||
is_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// UT: the pre-profiles install — one account, no PIN — never sees a picker.
|
||||
#[test]
|
||||
fn single_pinless_profile_resumes() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::None,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, true),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u1".to_string()
|
||||
},
|
||||
"a lone profile resumes even with the setting on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a PIN is not skippable by relaunching the app.
|
||||
#[test]
|
||||
fn pinned_last_profile_always_asks() {
|
||||
let profiles = vec![profile(
|
||||
"u1",
|
||||
UnlockMethod::Pin,
|
||||
Some("2026-01-01T00:00:00Z"),
|
||||
)];
|
||||
assert_eq!(startup_target(&profiles, false), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: several pinless profiles resume the last one unless asked to ask.
|
||||
#[test]
|
||||
fn multiple_profiles_follow_the_setting() {
|
||||
let profiles = vec![
|
||||
profile("u1", UnlockMethod::None, Some("2026-01-01T00:00:00Z")),
|
||||
profile("u2", UnlockMethod::None, Some("2026-02-01T00:00:00Z")),
|
||||
];
|
||||
assert_eq!(
|
||||
startup_target(&profiles, false),
|
||||
StartupTarget::Resume {
|
||||
user_id: "u2".to_string()
|
||||
},
|
||||
"resumes the most recently used"
|
||||
);
|
||||
assert_eq!(startup_target(&profiles, true), StartupTarget::Picker);
|
||||
}
|
||||
|
||||
/// UT: nothing signed in yet.
|
||||
#[test]
|
||||
fn no_profiles_shows_the_picker() {
|
||||
assert_eq!(startup_target(&[], false), StartupTarget::Picker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
|
||||
//!
|
||||
//! The policy half is deliberately pure — it takes the stored counter state and
|
||||
//! the current time, and returns the decision plus the next state. That is what
|
||||
//! makes "five wrong guesses then a lockout that survives a restart" testable
|
||||
//! without a database, a clock, or a running app.
|
||||
//!
|
||||
//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
|
||||
//! it does not encrypt that profile's access token, so anyone holding the
|
||||
//! database and the keyring has every token regardless. That trade is deliberate
|
||||
//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
|
||||
//! profile unable to resume its own downloads or drain its own sync queue until
|
||||
//! somebody walked past and typed the code.
|
||||
//!
|
||||
//! TRACES: UR-083 | DR-268
|
||||
|
||||
use argon2::Argon2;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
|
||||
/// Wrong guesses allowed before the first lockout.
|
||||
pub const MAX_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// How long the first lockout lasts. Each subsequent failure doubles it.
|
||||
const BASE_LOCKOUT_SECS: i64 = 60;
|
||||
|
||||
/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
|
||||
/// password route is always there, and a lockout measured in hours would push
|
||||
/// people towards not setting a PIN at all.
|
||||
const MAX_LOCKOUT_SECS: i64 = 15 * 60;
|
||||
|
||||
/// Persisted counter state for one profile's PIN.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinState {
|
||||
pub failed_count: u32,
|
||||
pub locked_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PinState {
|
||||
pub fn fresh() -> Self {
|
||||
Self {
|
||||
failed_count: 0,
|
||||
locked_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller should do with an attempt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PinDecision {
|
||||
Accept,
|
||||
Reject { attempts_remaining: u32 },
|
||||
Locked { until: DateTime<Utc> },
|
||||
}
|
||||
|
||||
/// Decide an attempt and produce the state to persist.
|
||||
///
|
||||
/// `pin_matches` is the result of the hash comparison; passing it in rather than
|
||||
/// doing the comparison here is what keeps this function pure and cheap to test
|
||||
/// across the whole attempt/lockout space.
|
||||
///
|
||||
/// A locked profile is refused *without consulting the hash*, so a caller cannot
|
||||
/// burn through a lockout by guessing quickly.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn evaluate(
|
||||
state: &PinState,
|
||||
now: DateTime<Utc>,
|
||||
pin_matches: bool,
|
||||
) -> (PinDecision, PinState) {
|
||||
if let Some(until) = state.locked_until {
|
||||
if now < until {
|
||||
return (PinDecision::Locked { until }, state.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if pin_matches {
|
||||
return (PinDecision::Accept, PinState::fresh());
|
||||
}
|
||||
|
||||
let failed_count = state.failed_count.saturating_add(1);
|
||||
|
||||
if failed_count >= MAX_ATTEMPTS {
|
||||
let over = i64::from(failed_count - MAX_ATTEMPTS);
|
||||
let secs = BASE_LOCKOUT_SECS
|
||||
.saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
|
||||
.min(MAX_LOCKOUT_SECS);
|
||||
let until = now + Duration::seconds(secs);
|
||||
(
|
||||
PinDecision::Locked { until },
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: Some(until),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: MAX_ATTEMPTS - failed_count,
|
||||
},
|
||||
PinState {
|
||||
failed_count,
|
||||
locked_until: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A PIN must be 4–8 digits. Rejecting non-digits here rather than in the pad
|
||||
/// keeps the rule where the rule is enforced.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn validate_pin(pin: &str) -> Result<(), String> {
|
||||
if pin.len() < 4 || pin.len() > 8 {
|
||||
return Err("PIN must be between 4 and 8 digits".to_string());
|
||||
}
|
||||
if !pin.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("PIN must contain only digits".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn hash_pin(pin: &str) -> Result<String, String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(pin.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| format!("Failed to hash PIN: {}", e))
|
||||
}
|
||||
|
||||
/// Compare a candidate PIN against a stored PHC string.
|
||||
///
|
||||
/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
|
||||
/// row should send the user down the password route, not wedge the picker.
|
||||
///
|
||||
/// TRACES: UR-083 | DR-268
|
||||
pub fn verify_pin(pin: &str, stored: &str) -> bool {
|
||||
match PasswordHash::new(stored) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(pin.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(e) => {
|
||||
log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn t0() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
/// UT: a correct PIN is accepted and clears any accumulated failures.
|
||||
#[test]
|
||||
fn correct_pin_accepts_and_resets() {
|
||||
let state = PinState {
|
||||
failed_count: 3,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: wrong guesses count down, and the count is what gets persisted —
|
||||
/// this is the half that must survive an app restart.
|
||||
#[test]
|
||||
fn wrong_pin_counts_down() {
|
||||
let mut state = PinState::fresh();
|
||||
for expected in (1..MAX_ATTEMPTS).rev() {
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
assert_eq!(
|
||||
decision,
|
||||
PinDecision::Reject {
|
||||
attempts_remaining: expected
|
||||
}
|
||||
);
|
||||
state = next;
|
||||
}
|
||||
assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
|
||||
}
|
||||
|
||||
/// UT: the attempt that exhausts the allowance locks out rather than
|
||||
/// reporting zero attempts remaining.
|
||||
#[test]
|
||||
fn exhausting_attempts_locks_out() {
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS - 1,
|
||||
locked_until: None,
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), false);
|
||||
match decision {
|
||||
PinDecision::Locked { until } => {
|
||||
assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
|
||||
}
|
||||
other => panic!("expected lockout, got {:?}", other),
|
||||
}
|
||||
assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
|
||||
}
|
||||
|
||||
/// UT: a locked profile is refused without the hash being consulted — the
|
||||
/// correct PIN does not shortcut an active lockout.
|
||||
#[test]
|
||||
fn lockout_refuses_even_a_correct_pin() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, t0(), true);
|
||||
assert_eq!(decision, PinDecision::Locked { until });
|
||||
assert_eq!(next, state, "a refused attempt must not extend the lockout");
|
||||
}
|
||||
|
||||
/// UT: once the window passes the profile accepts again.
|
||||
#[test]
|
||||
fn lockout_expires() {
|
||||
let until = t0() + Duration::seconds(60);
|
||||
let state = PinState {
|
||||
failed_count: MAX_ATTEMPTS,
|
||||
locked_until: Some(until),
|
||||
};
|
||||
let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
|
||||
assert_eq!(decision, PinDecision::Accept);
|
||||
assert_eq!(next, PinState::fresh());
|
||||
}
|
||||
|
||||
/// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
|
||||
/// PIN never becomes an hours-long wait.
|
||||
#[test]
|
||||
fn lockout_escalates_to_a_ceiling() {
|
||||
let mut seen = Vec::new();
|
||||
for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
|
||||
let state = PinState {
|
||||
failed_count: failed,
|
||||
locked_until: None,
|
||||
};
|
||||
if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
|
||||
seen.push((until - t0()).num_seconds());
|
||||
}
|
||||
}
|
||||
assert_eq!(seen[0], BASE_LOCKOUT_SECS);
|
||||
assert!(seen[1] > seen[0], "second lockout should be longer");
|
||||
assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
|
||||
assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
|
||||
}
|
||||
|
||||
/// UT: hashing round-trips, and a wrong PIN does not verify.
|
||||
#[test]
|
||||
fn hash_round_trips() {
|
||||
let hash = hash_pin("1234").unwrap();
|
||||
assert!(verify_pin("1234", &hash));
|
||||
assert!(!verify_pin("4321", &hash));
|
||||
}
|
||||
|
||||
/// UT: the stored hash never contains the PIN itself.
|
||||
#[test]
|
||||
fn hash_does_not_leak_the_pin() {
|
||||
let hash = hash_pin("246813").unwrap();
|
||||
assert!(!hash.contains("246813"));
|
||||
}
|
||||
|
||||
/// UT: an unreadable stored hash fails closed instead of erroring, so a
|
||||
/// corrupt row sends the user to the password route.
|
||||
#[test]
|
||||
fn corrupt_hash_fails_closed() {
|
||||
assert!(!verify_pin("1234", "not-a-phc-string"));
|
||||
}
|
||||
|
||||
/// UT: PIN shape is enforced in Rust, not in the pad.
|
||||
#[test]
|
||||
fn pin_shape_is_validated() {
|
||||
assert!(validate_pin("1234").is_ok());
|
||||
assert!(validate_pin("12345678").is_ok());
|
||||
assert!(validate_pin("123").is_err(), "too short");
|
||||
assert!(validate_pin("123456789").is_err(), "too long");
|
||||
assert!(validate_pin("12a4").is_err(), "non-digit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Profile switch orchestration, as a plan rather than a procedure.
|
||||
//!
|
||||
//! Switching profiles tears down and rebuilds nearly everything the app holds:
|
||||
//! the player and its queue, the sync queue drain, the session poller, the
|
||||
//! lockscreen metadata, the repository handle. The *ordering* of that teardown
|
||||
//! is a correctness invariant, not an implementation detail — a straggler that
|
||||
//! reports after the active user has flipped attributes one account's viewing to
|
||||
//! another, which is silent, plausible-looking, and unrecoverable.
|
||||
//!
|
||||
//! So the ordering lives here as a pure function returning a list of steps, and
|
||||
//! the command layer executes them. That is the only way this gets tested: an
|
||||
//! end-to-end switch needs two real accounts on a real server, which CI does not
|
||||
//! have and never will. The plan needs nothing.
|
||||
//!
|
||||
//! Two hazards worth remembering while executing a plan, both already paid for
|
||||
//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
|
||||
//! player event callback, and never hold a lock across a `match` scrutinee. A
|
||||
//! teardown reaches every one of those paths at once, from a new direction.
|
||||
//!
|
||||
//! TRACES: UR-082 | DR-270
|
||||
|
||||
/// One executable step of a switch.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SwitchStep {
|
||||
/// Stop playback and drop the queue. The queue cannot outlive its owner.
|
||||
StopPlayback,
|
||||
/// Flush what the outgoing profile changed while offline, so it is not
|
||||
/// replayed under the incoming profile's token.
|
||||
ParkSyncQueue {
|
||||
user_id: String,
|
||||
},
|
||||
StopSessionPoller,
|
||||
/// Clear OS media metadata so the lockscreen does not show the outgoing
|
||||
/// profile's episode to whoever just took over the device.
|
||||
ClearLockscreenMetadata,
|
||||
DestroyRepository,
|
||||
/// The point of no return: after this, writes land under the new profile.
|
||||
SetActiveUser {
|
||||
user_id: String,
|
||||
},
|
||||
BuildRepository {
|
||||
user_id: String,
|
||||
},
|
||||
StartSessionPoller,
|
||||
/// Re-derive what the server currently lets this profile see. Only possible
|
||||
/// online; offline the cached view stays as it was, which is stale-permissive
|
||||
/// by design.
|
||||
RefreshVisibility {
|
||||
user_id: String,
|
||||
},
|
||||
EmitSwitched {
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build the ordered plan for moving from `from` to `to`.
|
||||
///
|
||||
/// Switching to the profile that is already active is not a no-op — it is how an
|
||||
/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
|
||||
/// your own screen would stop the music. Only the emit survives.
|
||||
///
|
||||
/// TRACES: UR-082 | DR-270
|
||||
pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
|
||||
if from == Some(to) {
|
||||
return vec![SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
|
||||
if let Some(outgoing) = from {
|
||||
steps.push(SwitchStep::StopPlayback);
|
||||
steps.push(SwitchStep::ParkSyncQueue {
|
||||
user_id: outgoing.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StopSessionPoller);
|
||||
steps.push(SwitchStep::ClearLockscreenMetadata);
|
||||
steps.push(SwitchStep::DestroyRepository);
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::SetActiveUser {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::BuildRepository {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
steps.push(SwitchStep::StartSessionPoller);
|
||||
|
||||
if online {
|
||||
steps.push(SwitchStep::RefreshVisibility {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
steps.push(SwitchStep::EmitSwitched {
|
||||
user_id: to.to_string(),
|
||||
});
|
||||
|
||||
steps
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
|
||||
steps
|
||||
.iter()
|
||||
.position(|s| s == want)
|
||||
.unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
|
||||
}
|
||||
|
||||
/// UT: the invariant that prevents misattributed playback reports —
|
||||
/// everything belonging to the outgoing profile is torn down *before* the
|
||||
/// active user flips.
|
||||
#[test]
|
||||
fn teardown_precedes_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}
|
||||
) < flip
|
||||
);
|
||||
assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
|
||||
assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
|
||||
}
|
||||
|
||||
/// UT: the outgoing profile's queued offline mutations are parked under
|
||||
/// *its* id, never the incoming one's.
|
||||
#[test]
|
||||
fn sync_queue_is_parked_for_the_outgoing_profile() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert!(steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "dad".to_string()
|
||||
}));
|
||||
assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
|
||||
user_id: "kid".to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
/// UT: the repository is rebuilt only after the flip, so it cannot be
|
||||
/// constructed against a user id that is about to change.
|
||||
#[test]
|
||||
fn repository_is_rebuilt_after_the_flip() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
let flip = index_of(
|
||||
&steps,
|
||||
&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
index_of(
|
||||
&steps,
|
||||
&SwitchStep::BuildRepository {
|
||||
user_id: "kid".to_string()
|
||||
}
|
||||
) > flip
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: the switch is announced last, so nothing observing the event can
|
||||
/// catch the app mid-teardown.
|
||||
#[test]
|
||||
fn switch_is_announced_last() {
|
||||
let steps = plan(Some("dad"), "kid", true);
|
||||
assert_eq!(
|
||||
steps.last(),
|
||||
Some(&SwitchStep::EmitSwitched {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: first sign-in has nothing to tear down.
|
||||
#[test]
|
||||
fn cold_start_only_builds_up() {
|
||||
let steps = plan(None, "kid", true);
|
||||
assert!(!steps.contains(&SwitchStep::StopPlayback));
|
||||
assert!(!steps.contains(&SwitchStep::DestroyRepository));
|
||||
assert_eq!(
|
||||
steps.first(),
|
||||
Some(&SwitchStep::SetActiveUser {
|
||||
user_id: "kid".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: offline, visibility cannot be re-derived — the server is not there to
|
||||
/// say what this profile may see, and guessing would be worse than stale.
|
||||
#[test]
|
||||
fn offline_skips_visibility_refresh() {
|
||||
let steps = plan(Some("dad"), "kid", false);
|
||||
assert!(!steps
|
||||
.iter()
|
||||
.any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
|
||||
}
|
||||
|
||||
/// UT: dismissing an idle re-lock on your own profile must not stop the
|
||||
/// music you were listening to.
|
||||
#[test]
|
||||
fn unlocking_the_same_profile_does_not_disturb_playback() {
|
||||
let steps = plan(Some("dad"), "dad", true);
|
||||
assert_eq!(
|
||||
steps,
|
||||
vec![SwitchStep::EmitSwitched {
|
||||
user_id: "dad".to_string()
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("021_rebuild_items_fts", MIGRATION_021),
|
||||
("022_people_fts", MIGRATION_022),
|
||||
("023_downloads_expiry", MIGRATION_023),
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -819,3 +820,250 @@ ALTER TABLE downloads ADD COLUMN expires_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
|
||||
ON downloads(download_source, expires_at);
|
||||
"#;
|
||||
|
||||
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
|
||||
///
|
||||
/// Three tables, one purpose each:
|
||||
///
|
||||
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
|
||||
/// wrapping the access token, because a wrapped token would leave a locked
|
||||
/// profile unable to resume its own downloads or drain its own sync queue
|
||||
/// until someone typed the code. See DR-268 for why that trade was taken.
|
||||
/// - `user_item_visibility` records what the server has actually shown to each
|
||||
/// user. It is written as a byproduct of the cache write path, never rebuilt,
|
||||
/// so it cannot disagree with what the server returned.
|
||||
/// - `download_grants` separates the bytes from the claim on them, so one file
|
||||
/// can serve several profiles and is unlinked only when the last claim goes.
|
||||
///
|
||||
/// The backfill is not optional. Every existing cache row and download predates
|
||||
/// the concept of a user; without it an upgrading install's library goes blank.
|
||||
/// It grants the *active* user only — other pre-existing rows re-populate from
|
||||
/// the server on next browse, which is strictly safer than handing every
|
||||
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
|
||||
/// install whose single user somehow has `is_active = 0`.
|
||||
///
|
||||
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
|
||||
const MIGRATION_024: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS user_pins (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
pin_hash TEXT NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_item_visibility (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_libraries (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
library_id TEXT NOT NULL,
|
||||
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, library_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS download_grants (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
|
||||
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, download_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
|
||||
|
||||
-- Backfill: the active user has seen everything already cached on this device.
|
||||
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
|
||||
SELECT u.id, i.id
|
||||
FROM users u CROSS JOIN items i
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
|
||||
SELECT u.id, l.id
|
||||
FROM users u CROSS JOIN libraries l
|
||||
WHERE u.is_active = 1
|
||||
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
|
||||
|
||||
-- Downloads already record who asked for them, so every existing row becomes
|
||||
-- exactly one grant held by its original requester.
|
||||
INSERT OR IGNORE INTO download_grants (user_id, download_id)
|
||||
SELECT d.user_id, d.id FROM downloads d;
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Build a database at the schema version *before* multi-user profiles, so
|
||||
/// the backfill is exercised against rows that predate it — which is the
|
||||
/// only state that matters, and the one an in-memory database created from
|
||||
/// the full migration list can never reproduce.
|
||||
fn pre_024_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let upto = MIGRATIONS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.expect("migration 024 must be registered");
|
||||
for (_, sql) in &MIGRATIONS[..upto] {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed(conn: &Connection, active_user: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
|
||||
params![active_user],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn apply_024(conn: &Connection) {
|
||||
let (_, sql) = MIGRATIONS
|
||||
.iter()
|
||||
.find(|(name, _)| *name == "024_multi_user_profiles")
|
||||
.unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, sql: &str) -> i64 {
|
||||
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||
}
|
||||
|
||||
/// UT: upgrading an existing install does not blank its library. Without the
|
||||
/// backfill every cached item becomes invisible to the only user there is.
|
||||
#[test]
|
||||
fn backfill_keeps_the_existing_library_visible() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
|
||||
),
|
||||
1,
|
||||
"the active user must still see what was already cached"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an existing download becomes exactly one grant, held by whoever asked
|
||||
/// for it — the file is not orphaned and is not handed to anyone else.
|
||||
#[test]
|
||||
fn backfill_grants_downloads_to_their_requester() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: a second profile added later starts with an empty view. The cache was
|
||||
/// filled by someone else's browsing and the server never showed it to them,
|
||||
/// so inheriting it is the leak this whole table exists to close.
|
||||
#[test]
|
||||
fn a_later_profile_inherits_nothing() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
|
||||
),
|
||||
0,
|
||||
"a profile added after the upgrade must not inherit another's cache"
|
||||
);
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
|
||||
/// library back — the fallback arm of the backfill.
|
||||
#[test]
|
||||
fn backfill_covers_an_install_with_no_active_flag() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
|
||||
apply_024(&conn);
|
||||
|
||||
assert_eq!(
|
||||
count(
|
||||
&conn,
|
||||
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
|
||||
),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: removing a profile takes its per-user rows with it, so a re-added
|
||||
/// account starts clean rather than resuming someone's stale view.
|
||||
#[test]
|
||||
fn removing_a_profile_cascades_its_rows() {
|
||||
let conn = pre_024_db();
|
||||
seed(&conn, "dad");
|
||||
apply_024(&conn);
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
|
||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user