diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 6b6abeba4..629ef5b05 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -2907,10 +2907,12 @@ checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" name = "qbz-app" version = "0.1.0" dependencies = [ + "base64", "chrono", "dirs", "log", "md-5", + "rand 0.10.1", "rusqlite", "serde", "serde_json", diff --git a/crates/qbz-app/Cargo.toml b/crates/qbz-app/Cargo.toml index badf66ce3..44bcea3cc 100644 --- a/crates/qbz-app/Cargo.toml +++ b/crates/qbz-app/Cargo.toml @@ -14,3 +14,5 @@ rusqlite = { version = "0.31", features = ["bundled"] } dirs = "6" chrono = { workspace = true } md-5 = { workspace = true } +base64 = { workspace = true } +rand = "0.10" diff --git a/crates/qbz-app/src/settings/mod.rs b/crates/qbz-app/src/settings/mod.rs index e785fce94..5d6b8c4d6 100644 --- a/crates/qbz-app/src/settings/mod.rs +++ b/crates/qbz-app/src/settings/mod.rs @@ -2,5 +2,6 @@ pub mod favorites; pub mod legal; pub mod library; pub mod playback; +pub mod remote_control; pub mod subscription; pub mod tray; diff --git a/crates/qbz-app/src/settings/remote_control.rs b/crates/qbz-app/src/settings/remote_control.rs new file mode 100644 index 000000000..86eaee898 --- /dev/null +++ b/crates/qbz-app/src/settings/remote_control.rs @@ -0,0 +1,729 @@ +//! Remote control settings and allowed-origin persistence. +//! +//! This module owns portable storage only. Server startup, TLS certificate +//! management, QR generation, router/CORS application, and live restarts remain +//! host-owned behavior. + +use base64::Engine; +use rand::RngExt; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +const DEFAULT_ALLOWED_ORIGINS: &[&str] = &[ + "vicrodh.github.io", + "control.qbz.lol", + "www.control.qbz.lol", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteControlSettings { + pub enabled: bool, + pub port: u16, + pub secure: bool, + pub token: String, +} + +impl Default for RemoteControlSettings { + fn default() -> Self { + Self { + enabled: false, + port: 8182, + secure: true, + token: String::new(), + } + } +} + +pub struct RemoteControlSettingsStore { + conn: Connection, +} + +impl RemoteControlSettingsStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open remote control settings database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| { + format!( + "Failed to enable WAL for remote control settings database: {}", + e + ) + })?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS remote_control_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + port INTEGER NOT NULL DEFAULT 8182, + secure INTEGER NOT NULL DEFAULT 1, + token TEXT NOT NULL DEFAULT '' + );", + ) + .map_err(|e| format!("Failed to create remote control settings table: {}", e))?; + + ensure_secure_column(&conn)?; + + let token = generate_token(); + conn.execute( + "INSERT OR IGNORE INTO remote_control_settings (id, enabled, port, secure, token) + VALUES (1, 0, 8182, 0, ?1)", + params![token], + ) + .map_err(|e| format!("Failed to insert default remote control settings: {}", e))?; + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "remote_control_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "remote_control_settings.db") + } + + pub fn get_settings(&self) -> Result { + let mut settings = self + .conn + .query_row( + "SELECT enabled, port, secure, token FROM remote_control_settings WHERE id = 1", + [], + |row| { + let enabled: i32 = row.get(0)?; + let port: i64 = row.get(1)?; + let secure: i32 = row.get(2)?; + let token: String = row.get(3)?; + Ok(RemoteControlSettings { + enabled: enabled != 0, + port: port as u16, + secure: secure != 0, + token, + }) + }, + ) + .map_err(|e| format!("Failed to get remote control settings: {}", e))?; + + if settings.token.is_empty() { + settings.token = generate_token(); + self.set_token(&settings.token)?; + } + + Ok(settings) + } + + pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE remote_control_settings SET enabled = ?1 WHERE id = 1", + params![if enabled { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set remote control enabled: {}", e))?; + Ok(()) + } + + pub fn set_port(&self, port: u16) -> Result<(), String> { + self.conn + .execute( + "UPDATE remote_control_settings SET port = ?1 WHERE id = 1", + params![port as i64], + ) + .map_err(|e| format!("Failed to set remote control port: {}", e))?; + Ok(()) + } + + pub fn set_secure(&self, secure: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE remote_control_settings SET secure = ?1 WHERE id = 1", + params![if secure { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to set remote control secure: {}", e))?; + Ok(()) + } + + pub fn set_token(&self, token: &str) -> Result<(), String> { + self.conn + .execute( + "UPDATE remote_control_settings SET token = ?1 WHERE id = 1", + params![token], + ) + .map_err(|e| format!("Failed to set remote control token: {}", e))?; + Ok(()) + } + + pub fn regenerate_token(&self) -> Result { + let token = generate_token(); + self.set_token(&token)?; + Ok(token) + } +} + +pub struct RemoteControlSettingsState { + pub store: Arc>>, +} + +impl RemoteControlSettingsState { + pub fn new() -> Result { + Ok(Self { + store: Arc::new(Mutex::new(Some(RemoteControlSettingsStore::new()?))), + }) + } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = RemoteControlSettingsStore::new_at(base_dir)?; + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock remote control settings store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock remote control settings store".to_string())?; + *guard = None; + Ok(()) + } + + pub fn get_settings(&self) -> Result { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_settings() + } + + pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_enabled(enabled) + } + + pub fn set_port(&self, port: u16) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_port(port) + } + + pub fn set_secure(&self, secure: bool) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.set_secure(secure) + } + + pub fn regenerate_token(&self) -> Result { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.regenerate_token() + } +} + +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn ensure_secure_column(conn: &Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("PRAGMA table_info(remote_control_settings)") + .map_err(|e| format!("Failed to read settings schema: {}", e))?; + let mut rows = stmt + .query([]) + .map_err(|e| format!("Failed to read settings schema: {}", e))?; + + while let Some(row) = rows + .next() + .map_err(|e| format!("Schema read error: {}", e))? + { + let name: String = row + .get(1) + .map_err(|e| format!("Schema read error: {}", e))?; + if name == "secure" { + return Ok(()); + } + } + + conn.execute( + "ALTER TABLE remote_control_settings ADD COLUMN secure INTEGER NOT NULL DEFAULT 0", + [], + ) + .map_err(|e| format!("Failed to migrate remote control settings: {}", e))?; + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AllowedOrigin { + pub id: i64, + pub origin: String, + pub is_default: bool, +} + +pub struct AllowedOriginsStore { + conn: Connection, +} + +impl AllowedOriginsStore { + fn open_at(dir: &Path, db_name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = dir.join(db_name); + let conn = Connection::open(&db_path) + .map_err(|e| format!("Failed to open allowed origins database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to enable WAL for allowed origins database: {}", e))?; + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS allowed_origins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin TEXT NOT NULL UNIQUE, + is_default INTEGER NOT NULL DEFAULT 0 + );", + ) + .map_err(|e| format!("Failed to create allowed_origins table: {}", e))?; + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM allowed_origins", [], |row| row.get(0)) + .unwrap_or(0); + + if count == 0 { + for origin in DEFAULT_ALLOWED_ORIGINS { + let _ = conn.execute( + "INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)", + params![origin], + ); + } + } + + Ok(Self { conn }) + } + + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + Self::open_at(&data_dir, "remote_control_settings.db") + } + + pub fn new_at(base_dir: &Path) -> Result { + Self::open_at(base_dir, "remote_control_settings.db") + } + + pub fn get_origins(&self) -> Result, String> { + let mut stmt = self.conn + .prepare("SELECT id, origin, is_default FROM allowed_origins ORDER BY is_default DESC, origin ASC") + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let origins = stmt + .query_map([], |row| { + Ok(AllowedOrigin { + id: row.get(0)?, + origin: row.get(1)?, + is_default: row.get::<_, i32>(2)? != 0, + }) + }) + .map_err(|e| format!("Failed to query origins: {}", e))?; + + origins + .collect::, _>>() + .map_err(|e| format!("Failed to collect origins: {}", e)) + } + + pub fn is_origin_allowed(&self, origin: &str) -> bool { + self.conn + .query_row( + "SELECT 1 FROM allowed_origins WHERE origin = ?1", + params![origin], + |_| Ok(()), + ) + .is_ok() + } + + pub fn add_origin(&self, origin: &str) -> Result { + let normalized = origin.trim().to_lowercase(); + + if normalized.is_empty() { + return Err("Origin cannot be empty".to_string()); + } + + self.conn + .execute( + "INSERT INTO allowed_origins (origin, is_default) VALUES (?1, 0)", + params![normalized], + ) + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint") { + "Origin already exists".to_string() + } else { + format!("Failed to add origin: {}", e) + } + })?; + + let id = self.conn.last_insert_rowid(); + Ok(AllowedOrigin { + id, + origin: normalized, + is_default: false, + }) + } + + pub fn remove_origin(&self, id: i64) -> Result<(), String> { + let affected = self + .conn + .execute("DELETE FROM allowed_origins WHERE id = ?1", params![id]) + .map_err(|e| format!("Failed to remove origin: {}", e))?; + + if affected == 0 { + return Err("Origin not found".to_string()); + } + Ok(()) + } + + pub fn restore_defaults(&self) -> Result<(), String> { + for origin in DEFAULT_ALLOWED_ORIGINS { + let _ = self.conn.execute( + "INSERT OR IGNORE INTO allowed_origins (origin, is_default) VALUES (?1, 1)", + params![origin], + ); + } + Ok(()) + } +} + +pub struct AllowedOriginsState { + pub store: Arc>>, +} + +impl AllowedOriginsState { + pub fn new() -> Result { + Ok(Self { + store: Arc::new(Mutex::new(Some(AllowedOriginsStore::new()?))), + }) + } + + pub fn new_empty() -> Self { + Self { + store: Arc::new(Mutex::new(None)), + } + } + + pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { + let new_store = AllowedOriginsStore::new_at(base_dir)?; + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock allowed origins store".to_string())?; + *guard = Some(new_store); + Ok(()) + } + + pub fn teardown(&self) -> Result<(), String> { + let mut guard = self + .store + .lock() + .map_err(|_| "Failed to lock allowed origins store".to_string())?; + *guard = None; + Ok(()) + } + + pub fn get_origins(&self) -> Result, String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.get_origins() + } + + pub fn is_origin_allowed(&self, origin: &str) -> bool { + self.store + .lock() + .map(|guard| { + guard + .as_ref() + .map(|store| store.is_origin_allowed(origin)) + .unwrap_or(false) + }) + .unwrap_or(false) + } + + pub fn add_origin(&self, origin: &str) -> Result { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.add_origin(origin) + } + + pub fn remove_origin(&self, id: i64) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.remove_origin(id) + } + + pub fn restore_defaults(&self) -> Result<(), String> { + let guard = self + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + store.restore_defaults() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_test_dir(name: &str) -> std::path::PathBuf { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("qbz-app-{name}-{}-{nonce}", std::process::id())) + } + + fn fresh_remote_store(name: &str) -> (std::path::PathBuf, RemoteControlSettingsStore) { + let dir = unique_test_dir(name); + let store = RemoteControlSettingsStore::new_at(&dir).expect("open store in temp dir"); + (dir, store) + } + + fn fresh_origins_store(name: &str) -> (std::path::PathBuf, AllowedOriginsStore) { + let dir = unique_test_dir(name); + let store = AllowedOriginsStore::new_at(&dir).expect("open origins store in temp dir"); + (dir, store) + } + + #[test] + fn remote_control_default_struct_values_are_stable() { + let defaults = RemoteControlSettings::default(); + + assert!(!defaults.enabled); + assert_eq!(defaults.port, 8182); + assert!(defaults.secure); + assert!(defaults.token.is_empty()); + } + + #[test] + fn remote_control_store_creates_token_and_defaults_internal_remote_to_http() { + let (dir, store) = fresh_remote_store("remote-default"); + + let settings = store.get_settings().expect("get settings"); + + assert!(!settings.enabled); + assert_eq!(settings.port, 8182); + assert!(!settings.secure); + assert!(!settings.token.is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn remote_control_settings_persist_across_reopen() { + let dir = unique_test_dir("remote-persist"); + let token; + { + let store = RemoteControlSettingsStore::new_at(&dir).expect("open store"); + token = store.get_settings().expect("get settings").token; + store.set_enabled(true).expect("set enabled"); + store.set_port(9310).expect("set port"); + store.set_secure(true).expect("set secure"); + } + + let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store"); + let settings = reopened.get_settings().expect("get settings"); + + assert!(settings.enabled); + assert_eq!(settings.port, 9310); + assert!(settings.secure); + assert_eq!(settings.token, token); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn remote_control_regenerate_token_changes_and_persists_token() { + let dir = unique_test_dir("remote-token"); + let original; + let regenerated; + { + let store = RemoteControlSettingsStore::new_at(&dir).expect("open store"); + original = store.get_settings().expect("get settings").token; + regenerated = store.regenerate_token().expect("regenerate token"); + } + + let reopened = RemoteControlSettingsStore::new_at(&dir).expect("reopen store"); + let settings = reopened.get_settings().expect("get settings"); + + assert_ne!(original, regenerated); + assert_eq!(settings.token, regenerated); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn remote_control_empty_token_is_repaired_on_read() { + let dir = unique_test_dir("remote-empty-token"); + let db_path = dir.join("remote_control_settings.db"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + { + let conn = Connection::open(&db_path).expect("open db"); + conn.execute_batch( + "CREATE TABLE remote_control_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + port INTEGER NOT NULL DEFAULT 8182, + secure INTEGER NOT NULL DEFAULT 1, + token TEXT NOT NULL DEFAULT '' + ); + INSERT INTO remote_control_settings (id, enabled, port, secure, token) + VALUES (1, 0, 8182, 0, '');", + ) + .expect("create schema"); + } + + let store = RemoteControlSettingsStore::new_at(&dir).expect("open store"); + let settings = store.get_settings().expect("get settings"); + + assert!(!settings.token.is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn remote_control_legacy_schema_adds_secure_default_false() { + let dir = unique_test_dir("remote-legacy"); + let db_path = dir.join("remote_control_settings.db"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + { + let conn = Connection::open(&db_path).expect("open legacy db"); + conn.execute_batch( + "CREATE TABLE remote_control_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0, + port INTEGER NOT NULL DEFAULT 8182, + token TEXT NOT NULL DEFAULT '' + ); + INSERT INTO remote_control_settings (id, enabled, port, token) + VALUES (1, 1, 8183, 'legacy-token');", + ) + .expect("create legacy schema"); + } + + let store = RemoteControlSettingsStore::new_at(&dir).expect("migrate store"); + let settings = store.get_settings().expect("get settings"); + + assert!(settings.enabled); + assert_eq!(settings.port, 8183); + assert!(!settings.secure); + assert_eq!(settings.token, "legacy-token"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn allowed_origins_store_creates_defaults() { + let (dir, store) = fresh_origins_store("origins-default"); + + let origins = store.get_origins().expect("get origins"); + let names = origins + .iter() + .map(|origin| origin.origin.as_str()) + .collect::>(); + + assert_eq!( + names, + vec![ + "control.qbz.lol", + "vicrodh.github.io", + "www.control.qbz.lol" + ] + ); + assert!(origins.iter().all(|origin| origin.is_default)); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn allowed_origins_add_normalizes_and_remove_works() { + let (dir, store) = fresh_origins_store("origins-add-remove"); + + let origin = store + .add_origin(" EXAMPLE.com ") + .expect("add custom origin"); + assert_eq!(origin.origin, "example.com"); + assert!(!origin.is_default); + assert!(store.is_origin_allowed("example.com")); + + store.remove_origin(origin.id).expect("remove origin"); + assert!(!store.is_origin_allowed("example.com")); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn allowed_origins_rejects_empty_and_duplicate_origins() { + let (dir, store) = fresh_origins_store("origins-invalid"); + + assert!(store.add_origin(" ").is_err()); + store.add_origin("example.com").expect("add origin"); + let duplicate = store.add_origin("example.com").expect_err("duplicate"); + + assert_eq!(duplicate, "Origin already exists"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn allowed_origins_restore_defaults_adds_missing_defaults() { + let (dir, store) = fresh_origins_store("origins-restore"); + let origin = store.add_origin("example.com").expect("add origin"); + let default_id = store + .get_origins() + .expect("get origins") + .into_iter() + .find(|entry| entry.origin == "control.qbz.lol") + .expect("find default") + .id; + store.remove_origin(default_id).expect("remove default"); + + store.restore_defaults().expect("restore defaults"); + let origins = store.get_origins().expect("get origins"); + + assert!(origins.iter().any(|entry| entry.origin == "control.qbz.lol")); + assert!(origins.iter().any(|entry| entry.id == origin.id)); + let _ = std::fs::remove_dir_all(dir); + } +}